apple-tools-mcp 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -14
- package/index.js +4 -0
- package/lib/appleScript.js +129 -5
- package/lib/calendarWrite.js +796 -23
- package/lib/contactsWrite.js +1 -1
- package/lib/eventKitSession.js +369 -0
- package/lib/mailWrite.js +39 -3
- package/lib/messagesWrite.js +35 -3
- package/lib/shell.js +23 -5
- package/lib/writeGuards.js +8 -0
- package/lib/writeRouting.js +2 -2
- package/lib/writeTools.js +18 -3
- package/package.json +1 -1
- package/scripts/smoke-writes.js +184 -16
package/README.md
CHANGED
|
@@ -73,11 +73,13 @@ Full Disk Access covers the **read** tools. Write tools need the automation perm
|
|
|
73
73
|
|
|
74
74
|
Write tools drive Mail, Messages, Calendar, and Contacts through AppleScript. macOS gates those Apple events behind **Automation**, not by adding `node` to the Contacts or Calendars privacy lists.
|
|
75
75
|
|
|
76
|
+
**One-pass first-run:** Allow **`node`** to control **Mail**, **Messages**, **Contacts**, and **Calendar** in the same Automation pass. Contacts or Calendar being allowed does **not** grant Mail or Messages — they are separate Apple Events targets. A denied Mail grant hangs `mail_send` / `mail_draft` / `mail_reply` / `mail_forward` until the client disconnects; `dry_run` never talks to Mail, so that deny is invisible until a real compose.
|
|
77
|
+
|
|
76
78
|
**Do not add `node` via the + button in System Settings → Privacy & Security → Contacts or Calendars.** On current macOS those panes often have **no Add button**, and that instruction is not the ship-gate setup — it failed on the Mini.
|
|
77
79
|
|
|
78
80
|
#### Mini ship-gate host setup
|
|
79
81
|
|
|
80
|
-
This is the first-run flow for Contacts and Calendar **writes** on the Mac Mini. Do it on the Mini UI (or Screen Sharing to Mini), with the indexer LaunchAgent owning `node`:
|
|
82
|
+
This is the first-run flow for Mail, Messages, Contacts, and Calendar **writes** on the Mac Mini. Do it on the Mini UI (or Screen Sharing to Mini), with the indexer LaunchAgent owning `node`:
|
|
81
83
|
|
|
82
84
|
1. Open **System Settings → Privacy & Security → Automation**.
|
|
83
85
|
2. Run write prove-out with the **indexer LaunchAgent** owning `node` (`~/.apple-tools-mcp/writer.sock` / launchd). Against the tip, with the LaunchAgent up:
|
|
@@ -87,11 +89,17 @@ This is the first-run flow for Contacts and Calendar **writes** on the Mac Mini.
|
|
|
87
89
|
```
|
|
88
90
|
|
|
89
91
|
That is the ship-gate context. An embedded agent shell, IDE terminal, or MCP host app subprocess is **not** the ship-gate host unless the write bridge is up and the work executes inside launchd-owned `node`.
|
|
90
|
-
3. When prompts appear, click **Allow** for **`node`** to control **
|
|
91
|
-
4. **Full Disk Access** on `node` is a separate grant and covers **reads** (Mail / Messages / Calendar / AddressBook databases).
|
|
92
|
+
3. When prompts appear, click **Allow** for **`node`** to control **Mail**, **Messages**, **Contacts**, and **Calendar**. Watch the host — Allow **`node`** (the LaunchAgent binary), not any other app. Use whatever path the LaunchAgent plist / `which node` reports — `/Users/petercoates/.local/node/bin/node` is a Mini *example* only, not a universal path. Do **not** approve the MCP client / host app that launched a short-lived stdio server.
|
|
93
|
+
4. **Full Disk Access** on `node` is a separate grant and covers **reads** (Mail / Messages / Calendar / AddressBook databases). Write tools need the Automation / Apple Events grants to Mail.app (`mail_send`, `mail_draft`, `mail_reply`, `mail_forward`, and the other mail writes), Messages.app (`messages_send`), Contacts.app, and Calendar.app.
|
|
92
94
|
5. npm Trusted Publisher / publish tokens are unrelated to TCC. Do not confuse them with this setup.
|
|
93
95
|
|
|
94
|
-
If you dismissed a prompt, re-open **Automation** and turn the **node → Contacts** / **node → Calendar** toggles back on. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Do not use `tccutil reset AddressBook` / `tccutil reset Calendar`, and do not add `node` via Settings **+** into the Contacts or Calendars privacy lists — those panes often have no Add button, and that is not how this ship gate is granted.
|
|
96
|
+
If you dismissed a prompt, re-open **Automation** and turn the **node → Mail** / **node → Messages** / **node → Contacts** / **node → Calendar** toggles back on. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Do not use `tccutil reset AddressBook` / `tccutil reset Calendar`, and do not add `node` via Settings **+** into the Contacts or Calendars privacy lists — those panes often have no Add button, and that is not how this ship gate is granted.
|
|
97
|
+
|
|
98
|
+
#### `dry_run` does not prove Mail Automation
|
|
99
|
+
|
|
100
|
+
`mail_send` / `mail_draft` / `mail_reply` / `mail_forward` with `dry_run=true` return immediately and never send Apple events to Mail — dry_run never talks to Mail. A TCC deny for **node → Mail** is therefore invisible until a real compose (`make new outgoing message`). The hang is **Automation denied**, not “Mail.app could not be reached” / “app not available”.
|
|
101
|
+
|
|
102
|
+
`tell application "Mail" to get name` can succeed while compose still hangs. The smoke test’s Mail step runs that real compose (then discards the outgoing message, or on an older daemon saves a clearly named Draft) so the deny fails **setup**, not a later production `mail_send`. The same Allow-via-prompt applies to **Messages** for `messages_send`; `messages_send` with `dry_run=true` likewise never talks to Messages.app. Smoke also live-enumerates Messages accounts (nothing is sent). **`--apply` fails closed if Mail, Messages, Contacts, or Calendar Automation is missing.**
|
|
95
103
|
|
|
96
104
|
#### Which process macOS is actually asking about
|
|
97
105
|
|
|
@@ -140,7 +148,7 @@ So the supported configuration for writes is: **run the indexer daemon** ([Launc
|
|
|
140
148
|
|
|
141
149
|
If a write is denied and no daemon is listening, the tool says so and tells you to start `apple-tools-indexer`, rather than failing with a bare AppleScript error. Contacts and Calendar denials each name their own privacy class and note that reads are unaffected.
|
|
142
150
|
|
|
143
|
-
#### Verifying Contacts and Calendar CRUD on a Node host
|
|
151
|
+
#### Verifying Mail Automation plus Contacts and Calendar CRUD on a Node host
|
|
144
152
|
|
|
145
153
|
The smoke test drives writes through **the same dispatcher the MCP tools use**, so when the write bridge is up the work executes inside the indexer daemon. That matters: the thing being proven is the shipping path, not the Automation rights of whatever shell you happened to type the command into.
|
|
146
154
|
|
|
@@ -155,7 +163,7 @@ The smoke test drives writes through **the same dispatcher the MCP tools use**,
|
|
|
155
163
|
|
|
156
164
|
2. **Check out the tip / unpack the tarball** you are gating, in a short-lived directory. The global install stays untouched.
|
|
157
165
|
|
|
158
|
-
3. **Dry run first.** It creates, edits, and deletes nothing — but it is not a no-op: it reads your contacts from the AddressBook database
|
|
166
|
+
3. **Dry run first.** It creates, edits, and deletes nothing on Contacts/Calendar — but it is not a no-op: it reads your contacts from the AddressBook database, calls `calendar_list_calendars` (a **live Calendar.app query and therefore a real TCC touch**), runs a **live Mail compose** (`make new outgoing message`) because `mail_send` `dry_run` never touches Mail, and live-enumerates **Messages** accounts because `messages_send` `dry_run` never touches Messages. A refused Calendar listing, Mail compose, or Messages lookup is `WARN` on a dry run. `--apply` fails closed if **Mail, Messages, Contacts, or Calendar** Automation is still denied.
|
|
159
167
|
|
|
160
168
|
```bash
|
|
161
169
|
npm run smoke:writes
|
|
@@ -169,7 +177,7 @@ The smoke test drives writes through **the same dispatcher the MCP tools use**,
|
|
|
169
177
|
node scripts/smoke-writes.js --apply --keep # leave the test items behind
|
|
170
178
|
```
|
|
171
179
|
|
|
172
|
-
The header prints the write path it chose (`indexer daemon via write bridge` or `in this process`), and the read path (sqlite + FDA) is reported separately from the
|
|
180
|
+
The header prints the write path it chose (`indexer daemon via write bridge` or `in this process`), and the read path (sqlite + FDA) is reported separately from the write paths (Mail.app compose, Contacts.app, Calendar.app), so a failure tells you which mechanism refused. A Mail hang or timeout is **TCC / Automation denied**, not “app not available”.
|
|
173
181
|
|
|
174
182
|
**The parent process matters.** With `--apply` and **no bridge listening**, the smoke test **refuses to run** rather than executing in-process and calling the result a package failure. Running it from an embedded agent shell, an IDE terminal, or a host app's subprocess is *not* the ship-gate context on its own, because macOS attributes the Apple events to that parent. Either start the LaunchAgent (preferred, and what production clients use), or run it from **Terminal.app**, where node is the responsible process, and pass `--allow-local` to acknowledge that:
|
|
175
183
|
|
|
@@ -177,7 +185,7 @@ The header prints the write path it chose (`indexer daemon via write bridge` or
|
|
|
177
185
|
node scripts/smoke-writes.js --apply --allow-local # only from Terminal.app / launchd
|
|
178
186
|
```
|
|
179
187
|
|
|
180
|
-
Expected results: **PASS on the Node host with the bridge up — this is the ship gate for Contacts and Calendar writes.** On Claude Desktop with no daemon running, Contacts and Calendar CRUD are both expected to fail; that is the documented host limitation above, not a regression.
|
|
188
|
+
Expected results: **PASS on the Node host with the bridge up — this is the ship gate for Mail, Messages, Contacts, and Calendar writes.** `--apply` fails closed if any of those four Automation grants is missing. On Claude Desktop with no daemon running, Contacts and Calendar CRUD are both expected to fail; that is the documented host limitation above, not a regression. Mail and Messages still need their own **node → Mail** / **node → Messages** grants; a Contacts/Calendar grant does not cover them.
|
|
181
189
|
|
|
182
190
|
### 3. Configure your MCP client
|
|
183
191
|
|
|
@@ -266,7 +274,7 @@ Missing `config.json` is fine — env then the 5-minute default apply.
|
|
|
266
274
|
|
|
267
275
|
On Mini, run the **indexer daemon**, not a sleep-pipe wrapper around `apple-tools-mcp`. Claude Desktop and other clients still attach via short-lived stdio MCP (`npx -y apple-tools-mcp` or the global `apple-tools-mcp` bin).
|
|
268
276
|
|
|
269
|
-
The daemon does two jobs: it refreshes the vector index, and it serves the **write bridge** at `~/.apple-tools-mcp/writer.sock` so stdio clients can perform Contacts/Calendar writes that their host app cannot be granted (see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate)). When macOS prompts, Allow **`node`** (the LaunchAgent binary) to control Contacts.app and Calendar.app. Do not approve the MCP client / host app that launched a short-lived stdio server, and do not try to add `node` via **+** in the Contacts or Calendars privacy lists.
|
|
277
|
+
The daemon does two jobs: it refreshes the vector index, and it serves the **write bridge** at `~/.apple-tools-mcp/writer.sock` so stdio clients can perform Mail / Messages / Contacts / Calendar writes that their host app cannot be granted (see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate)). When macOS prompts, Allow **`node`** (the LaunchAgent binary) to control Mail.app, Messages.app, Contacts.app, and Calendar.app. Do not approve the MCP client / host app that launched a short-lived stdio server, and do not try to add `node` via **+** in the Contacts or Calendars privacy lists.
|
|
270
278
|
|
|
271
279
|
The bridge is created before the daemon touches the vector index, so writes stay available even when the index is missing, locked, or mid-rebuild. Confirm it after an upgrade with `ls -l ~/.apple-tools-mcp/writer.sock` (it should be a `srw-------` socket); the daemon removes it on shutdown.
|
|
272
280
|
|
|
@@ -438,12 +446,20 @@ Supported identifiers:
|
|
|
438
446
|
|------|-----------|--------------|
|
|
439
447
|
| `calendar_list_calendars` | none | none (read-only helper) |
|
|
440
448
|
| `calendar_add` | `calendar_name` (required), `title` (required), `start` (required), `end`, `all_day`, `location`, `notes`, recurrence args, `alerts_minutes_before[]` | none |
|
|
441
|
-
| `calendar_edit` | `event_id` (required) plus any of `title`, `start`, `end`, `location`, `notes`, recurrence args, `alerts_minutes_before[]`, `replace_alerts` | none |
|
|
442
|
-
| `calendar_remove` | `event_id` (required) | **`confirm` required** |
|
|
449
|
+
| `calendar_edit` | `event_id` (required), optional `eventkit_id` from add, plus any of `title`, `start`, `end`, `location`, `notes`, recurrence args, `alerts_minutes_before[]`, `replace_alerts` | none |
|
|
450
|
+
| `calendar_remove` | `event_id` (required), optional `eventkit_id` from add, optional `calendar_name` | **`confirm` required** |
|
|
443
451
|
| `calendar_rsvp` | `event_id` (required), `response` (`accept` \| `decline` \| `tentative`), `attendee_email` | none |
|
|
444
452
|
|
|
445
453
|
Events are addressed by their **iCalendar UID**, reported as `Event ID` by `calendar_date` and returned by `calendar_add`. Run `calendar_list_calendars` first so new events land on the intended calendar instead of the default one.
|
|
446
454
|
|
|
455
|
+
`calendar_add`, `calendar_edit`, and `calendar_remove` share the same write-bridge RPC (`{ tool, args }` into launchd-owned node). They do not use a different socket or calendar account. Calendar.app’s dictionary has **`delete` only** — there is no `remove` or `move to trash`.
|
|
456
|
+
|
|
457
|
+
Non-recurring `calendar_add` **must** create through **EventKit** and print `via: EventKit` plus `eventkit_id`. Mini `cd74071`: EventKit had `eventKitCalendars=1` but `default=[id NSTaggedPointerString]` — JXA `String(title)` prints the ObjC class, not the calendar name, so the title match missed the only writable calendar. Titles and `calendarIdentifier` are `ObjC.unwrap`d; a single writable calendar or `defaultCalendarForNewEvents` is used when the name does not match. There is **no AppleScript fallback** for non-recurring add.
|
|
458
|
+
|
|
459
|
+
`calendar_edit` and `calendar_remove` take `eventkit_id` from add (`calendarUUID:eventUUID` on Mini). writeOnly EventKit (`status=4`) can create and read ids off the saved `EKEvent`, but **cannot re-query** via `eventWithIdentifier` — Mini `2cdf44d` failed add on that post-save lookup. Add therefore returns ids from the in-memory event. The indexer write-bridge keeps a long-lived EventKit osascript session that caches the `EKEvent` so edit/remove call `saveEvent` / `removeEvent` without a fetch. When `eventkit_id` is present and the session misses, Calendar.app uid lookup is skipped so iCloud cannot hang. `ETIMEDOUT` / `-1712` is **`timeout`**, never TCC. Failures print `osascript kind=… error=… codes=…`. `--apply` fails closed unless add printed `via: EventKit` and `eventkit_id`.
|
|
460
|
+
|
|
461
|
+
`--apply` prefers an **On My Mac** calendar when EventKit lists one. `--calendar=` still wins. The Mini host’s first writable calendar named **Calendar** (no On My Mac / iCloud label) is fine if EventKit create actually runs.
|
|
462
|
+
|
|
447
463
|
Like Contacts, calendar writes go through Calendar.app rather than writing `Calendar.sqlitedb` directly. Reads query that database for speed, but edits must go through the app so iCloud sync, invitations, and alarms behave correctly.
|
|
448
464
|
|
|
449
465
|
**Supported recurrence patterns.** Either pass structured arguments or a raw `recurrence` RRULE:
|
|
@@ -526,12 +542,13 @@ Ensure Node.js has Full Disk Access (see Installation step 2).
|
|
|
526
542
|
|
|
527
543
|
The message names which process macOS was actually asking about. Work through it in this order:
|
|
528
544
|
|
|
529
|
-
1. **Is the indexer daemon running?** `pgrep -fl apple-tools-indexer`. If not, start it — the daemon is the supported host for Contacts and Calendar writes (see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate)).
|
|
530
|
-
2. **Did you approve the Automation prompts for `node`?** Open System Settings → Privacy & Security → **Automation** and confirm **node** (the LaunchAgent binary — whatever path the plist / `which node` reports; `/Users/petercoates/.local/node/bin/node` is a Mini *example* only) is allowed to control Contacts and Calendar. Do **not** approve the MCP client / host app that launched a short-lived stdio server, and do **not** try to add `node` via **+** in the Contacts or Calendars privacy lists — those panes often have no Add button. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Full Disk Access is a separate read grant; npm Trusted Publisher / tokens are unrelated.
|
|
545
|
+
1. **Is the indexer daemon running?** `pgrep -fl apple-tools-indexer`. If not, start it — the daemon is the supported host for Mail, Messages, Contacts, and Calendar writes (see [step 2b](#2b-grant-automation-for-write-tools-first-run--ship-gate)).
|
|
546
|
+
2. **Did you approve the Automation prompts for `node`?** Open System Settings → Privacy & Security → **Automation** and confirm **node** (the LaunchAgent binary — whatever path the plist / `which node` reports; `/Users/petercoates/.local/node/bin/node` is a Mini *example* only) is allowed to control **Mail**, **Messages**, Contacts, and Calendar. Watch the host — Allow **`node`**, not any other app. Contacts or Calendar being allowed does **not** grant Mail. A `mail_send` hang is **Automation denied**, not “Mail.app could not be reached”; `dry_run` never talks to Mail so it cannot detect this. Do **not** approve the MCP client / host app that launched a short-lived stdio server, and do **not** try to add `node` via **+** in the Contacts or Calendars privacy lists — those panes often have no Add button. `tccutil reset AppleEvents` re-arms the Automation prompt so you can Allow **`node`** again. Full Disk Access is a separate read grant; npm Trusted Publisher / tokens are unrelated.
|
|
531
547
|
3. **Is the daemon's node binary the one with Full Disk Access?** LaunchAgents do not inherit your shell `PATH`; confirm the plist points at the same path `which node` reports.
|
|
532
548
|
4. **Is it actually the write path?** Contacts or Calendar *reads* failing with `EPERM` is a Full Disk Access / attribution problem, not the entitlement gap — fix FDA for the responsible process. Contacts or Calendar *CRUD* failing with no prompt under Claude Desktop is the [documented host limitation](#reads-and-writes-are-different-mechanisms): Claude.app carries neither the addressbook nor the calendars entitlement. Start the daemon and the write succeeds through the bridge.
|
|
533
549
|
5. **"Contacts.app / Calendar.app could not be reached"** with the app clearly installed is an **Automation / responsible-process** failure, not a missing app — macOS reports a refused Apple event as `-1728` / "can't get application". The tool says so and points at the bridge. Start the LaunchAgent, or run from Terminal.app and approve the Automation prompt.
|
|
534
|
-
6. **
|
|
550
|
+
6. **`calendar_remove` FAIL after add/edit PASS** is a delete-path failure, not missing Calendar Automation. Same write-bridge RPC. Read the `osascript kind=/error=` line: `kind=timeout` + `ETIMEDOUT` / `-1712` is an iCloud/CalDAV hang or Calendar confirmation dialog — **not** TCC. `EVENTKIT_NOT_FOUND status=4` is writeOnly EventKit failing to see an AppleScript-created event (add must go through EventKit). `-1743` / `-10004` is a real Automation deny. Paste that line if it still FAILs.
|
|
551
|
+
7. **Prove the host itself works** with `node scripts/smoke-writes.js --apply` on the Node host with the LaunchAgent running; it routes through the bridge and separates the read and write mechanisms for you.
|
|
535
552
|
|
|
536
553
|
### A write returned "CONFIRMATION REQUIRED"
|
|
537
554
|
|
package/index.js
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
executeWriteToolLocally
|
|
30
30
|
} from "./lib/writeTools.js";
|
|
31
31
|
import { startWriteBridgeServer, defaultSocketPath } from "./lib/writeBridge.js";
|
|
32
|
+
import { closeEventKitSession, ensureEventKitSession } from "./lib/eventKitSession.js";
|
|
33
|
+
import { EVENTKIT_JXA_HELPERS } from "./lib/calendarWrite.js";
|
|
32
34
|
|
|
33
35
|
const PACKAGE_VERSION = JSON.parse(
|
|
34
36
|
fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
|
|
@@ -83,6 +85,7 @@ const WRITE_SOCKET_PATH = defaultSocketPath();
|
|
|
83
85
|
let writeBridge = null;
|
|
84
86
|
|
|
85
87
|
function stopWriteBridge() {
|
|
88
|
+
closeEventKitSession();
|
|
86
89
|
if (!writeBridge) return;
|
|
87
90
|
try {
|
|
88
91
|
writeBridge.close();
|
|
@@ -99,6 +102,7 @@ async function startWriteBridge() {
|
|
|
99
102
|
handler: (tool, args) => executeWriteToolLocally(tool, args),
|
|
100
103
|
log: (msg) => console.error(msg)
|
|
101
104
|
});
|
|
105
|
+
ensureEventKitSession({ helpers: EVENTKIT_JXA_HELPERS });
|
|
102
106
|
} catch (e) {
|
|
103
107
|
console.error(`Write bridge unavailable: ${e.message}. Writes will run in each MCP process.`);
|
|
104
108
|
}
|
package/lib/appleScript.js
CHANGED
|
@@ -24,6 +24,7 @@ export const DEFAULT_SCRIPT_TIMEOUT_MS = 30000;
|
|
|
24
24
|
export const NOT_FOUND_SENTINELS = [
|
|
25
25
|
"MESSAGE_NOT_FOUND",
|
|
26
26
|
"EVENT_NOT_FOUND",
|
|
27
|
+
"EVENTKIT_NOT_FOUND",
|
|
27
28
|
"CONTACT_NOT_FOUND",
|
|
28
29
|
"CALENDAR_NOT_FOUND",
|
|
29
30
|
"CHAT_NOT_FOUND",
|
|
@@ -69,11 +70,27 @@ const APP_MISSING_SIGNATURES = [
|
|
|
69
70
|
|
|
70
71
|
// osascript itself is missing: not macOS, or a stripped PATH. Never an
|
|
71
72
|
// attribution problem, because nothing ran.
|
|
73
|
+
//
|
|
74
|
+
// Do **not** match a bare "spawnSync osascript" here. A TCC-denied Mail
|
|
75
|
+
// compose hangs until spawnSync times out (`spawnSync osascript ETIMEDOUT`);
|
|
76
|
+
// that string also contains "spawnSync osascript" and must not be reported
|
|
77
|
+
// as "Mail.app could not be reached".
|
|
72
78
|
const OSASCRIPT_MISSING_SIGNATURES = [
|
|
73
|
-
"spawnsync osascript",
|
|
79
|
+
"spawnsync osascript enoent",
|
|
74
80
|
"enoent"
|
|
75
81
|
];
|
|
76
82
|
|
|
83
|
+
// Hung Apple events. Mini diagnosis: `tell Mail to get name` returns, but
|
|
84
|
+
// `make new outgoing message` blocks until timeout when node → Mail
|
|
85
|
+
// Automation is denied. That hang is a TCC deny, not a missing app.
|
|
86
|
+
const TIMEOUT_SIGNATURES = [
|
|
87
|
+
"etimedout",
|
|
88
|
+
"timed out after",
|
|
89
|
+
"-1712",
|
|
90
|
+
"appleevent timed out",
|
|
91
|
+
"apple event timed out"
|
|
92
|
+
];
|
|
93
|
+
|
|
77
94
|
/**
|
|
78
95
|
* Standard install locations for the apps this package automates.
|
|
79
96
|
* Ventura and later keep the first-party apps in /System/Applications.
|
|
@@ -98,7 +115,7 @@ export function appBundleInstalled(appName, existsFn = fs.existsSync) {
|
|
|
98
115
|
* @param {string} message
|
|
99
116
|
* @param {object} [context]
|
|
100
117
|
* @param {boolean|null} [context.appInstalled] - whether the target app exists
|
|
101
|
-
* @returns {"tcc"|"not_found"|"attribution"|"app_unavailable"|"unknown"}
|
|
118
|
+
* @returns {"tcc"|"timeout"|"not_found"|"attribution"|"app_unavailable"|"unknown"}
|
|
102
119
|
*/
|
|
103
120
|
export function classifyAppleScriptError(message, context = {}) {
|
|
104
121
|
const text = String(message || "").toLowerCase();
|
|
@@ -106,7 +123,21 @@ export function classifyAppleScriptError(message, context = {}) {
|
|
|
106
123
|
if (text.includes(sentinel.toLowerCase())) return "not_found";
|
|
107
124
|
}
|
|
108
125
|
if (OSASCRIPT_MISSING_SIGNATURES.some((sig) => text.includes(sig))) return "app_unavailable";
|
|
126
|
+
// ETIMEDOUT / -1712 is a hang, not a TCC deny. Mail/Messages still map
|
|
127
|
+
// this kind to Automation guidance (compose hang = grant missing).
|
|
128
|
+
// calendar_remove must never print Calendar-denied copy for ETIMEDOUT alone.
|
|
129
|
+
if (TIMEOUT_SIGNATURES.some((sig) => text.includes(sig))) return "timeout";
|
|
109
130
|
if (TCC_SIGNATURES.some((sig) => text.includes(sig))) return "tcc";
|
|
131
|
+
// Calendar delete of a detached event specifier often returns
|
|
132
|
+
// "Can't get event … (-1728)". That is a missing object, not a missing app.
|
|
133
|
+
// Check before the generic -1728 / "can't get application" path.
|
|
134
|
+
if (
|
|
135
|
+
text.includes("can't get event") ||
|
|
136
|
+
text.includes("can't get calendar") ||
|
|
137
|
+
text.includes("can't get theevent")
|
|
138
|
+
) {
|
|
139
|
+
return "not_found";
|
|
140
|
+
}
|
|
110
141
|
if (APP_MISSING_SIGNATURES.some((sig) => text.includes(sig))) {
|
|
111
142
|
// The app is on disk, so "can't get application" means macOS refused to
|
|
112
143
|
// let the responsible process drive it - an Automation / attribution
|
|
@@ -120,6 +151,75 @@ export function isTccDenial(message) {
|
|
|
120
151
|
return classifyAppleScriptError(message) === "tcc";
|
|
121
152
|
}
|
|
122
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Real Automation / privacy deny codes — not a hung Calendar.app delete.
|
|
156
|
+
*
|
|
157
|
+
* Mini calendar_remove: add/edit succeed (Automation granted), then delete
|
|
158
|
+
* hits spawnSync ETIMEDOUT / AppleEvent -1712. That is an iCloud/CalDAV
|
|
159
|
+
* hang or a confirmation dialog, not -1743 / -10004. Callers must not
|
|
160
|
+
* rewrite that as CALENDAR_TCC_GUIDANCE.
|
|
161
|
+
*/
|
|
162
|
+
const HARD_TCC_SIGNATURES = [
|
|
163
|
+
"-1743",
|
|
164
|
+
"-10004",
|
|
165
|
+
"-25211",
|
|
166
|
+
"not authorized to send apple events",
|
|
167
|
+
"not allowed to send apple events",
|
|
168
|
+
"is not allowed assistive access",
|
|
169
|
+
"operation not permitted",
|
|
170
|
+
"not permitted to access",
|
|
171
|
+
"access to contacts",
|
|
172
|
+
"access to calendars",
|
|
173
|
+
"privacy settings",
|
|
174
|
+
"errae eventnotpermitted",
|
|
175
|
+
"errAEEventNotPermitted".toLowerCase()
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
export function isHardTccDenial(message) {
|
|
179
|
+
const text = String(message || "").toLowerCase();
|
|
180
|
+
return HARD_TCC_SIGNATURES.some((sig) => text.includes(sig));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const APPLEEVENT_CODES = [
|
|
184
|
+
"-1743",
|
|
185
|
+
"-10004",
|
|
186
|
+
"-25211",
|
|
187
|
+
"-1712",
|
|
188
|
+
"-1728",
|
|
189
|
+
"-600",
|
|
190
|
+
"-10810",
|
|
191
|
+
"-1708",
|
|
192
|
+
"-1719",
|
|
193
|
+
"-10025",
|
|
194
|
+
"-2700"
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Pull known AppleEvent / spawn codes out of osascript stderr so smoke
|
|
199
|
+
* can print them instead of guessing TCC.
|
|
200
|
+
*/
|
|
201
|
+
export function extractAppleEventCodes(message) {
|
|
202
|
+
const text = String(message || "");
|
|
203
|
+
const found = [];
|
|
204
|
+
for (const code of APPLEEVENT_CODES) {
|
|
205
|
+
if (text.includes(code) && !found.includes(code)) found.push(code);
|
|
206
|
+
}
|
|
207
|
+
if (/etimedout/i.test(text) && !found.includes("ETIMEDOUT")) found.push("ETIMEDOUT");
|
|
208
|
+
return found;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* One-line osascript diagnostic. Always include this on calendar_remove
|
|
213
|
+
* failure so Mini --apply stops looking like a TCC deny.
|
|
214
|
+
*/
|
|
215
|
+
export function formatOsascriptDiagnostic(result, source = "osascript") {
|
|
216
|
+
const raw = String(result && result.error ? result.error : "").replace(/\s+/g, " ").trim();
|
|
217
|
+
const clipped = raw.length > 360 ? `${raw.slice(0, 360)}...` : raw;
|
|
218
|
+
const kind = (result && result.kind) || "unknown";
|
|
219
|
+
const codes = extractAppleEventCodes(raw);
|
|
220
|
+
return `${source} kind=${kind} error=${clipped || "(empty)"}${codes.length ? ` codes=${codes.join(",")}` : ""}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
123
223
|
/**
|
|
124
224
|
* Guidance attached to TCC denials. Names the host constraint without
|
|
125
225
|
* claiming the package can grant another app's entitlements.
|
|
@@ -168,11 +268,35 @@ export const ATTRIBUTION_GUIDANCE =
|
|
|
168
268
|
"under launchd-owned node, or run this server from a parent that can hold Automation access (Terminal.app) and approve the prompt.";
|
|
169
269
|
|
|
170
270
|
/**
|
|
171
|
-
*
|
|
271
|
+
* Mail writes are gated by Automation → Mail for the responsible process.
|
|
272
|
+
* A hang or timeout on compose/send is that deny — not "Mail.app missing".
|
|
273
|
+
* dry_run never sends Apple events to Mail, so it cannot detect the grant.
|
|
274
|
+
*/
|
|
275
|
+
export const MAIL_TCC_GUIDANCE =
|
|
276
|
+
"macOS denied Mail automation (Apple Events to Mail.app). A hang or timeout on compose or send is a TCC / Automation deny for node → Mail, " +
|
|
277
|
+
"not Mail.app missing or unavailable. dry_run never talks to Mail, so it cannot detect this grant. " +
|
|
278
|
+
"Allow node in System Settings → Privacy & Security → Automation for Mail (same Allow-via-prompt as Contacts and Calendar — do not add node via + in a privacy list). " +
|
|
279
|
+
"A Contacts or Calendar grant does not include Mail. " +
|
|
280
|
+
"Run the indexer daemon (apple-tools-indexer / the LaunchAgent) so Mail writes execute under node.";
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Messages writes are a separate Automation target from Mail / Contacts / Calendar.
|
|
284
|
+
*/
|
|
285
|
+
export const MESSAGES_TCC_GUIDANCE =
|
|
286
|
+
"macOS denied Messages automation (Apple Events to Messages.app). A hang or timeout on send is a TCC / Automation deny for node → Messages, " +
|
|
287
|
+
"not Messages.app missing or unavailable. dry_run never talks to Messages, so it cannot detect this grant. " +
|
|
288
|
+
"Allow node in System Settings → Privacy & Security → Automation for Messages (same Allow-via-prompt as Mail, Contacts, and Calendar — do not add node via + in a privacy list). " +
|
|
289
|
+
"A Contacts or Calendar grant does not include Messages. " +
|
|
290
|
+
"Run the indexer daemon (apple-tools-indexer / the LaunchAgent) so Messages writes execute under node.";
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {"contacts"|"calendar"|"mail"|"messages"|string} source
|
|
172
294
|
*/
|
|
173
295
|
export function tccGuidanceFor(source) {
|
|
174
296
|
if (source === "contacts") return CONTACTS_TCC_GUIDANCE;
|
|
175
297
|
if (source === "calendar") return CALENDAR_TCC_GUIDANCE;
|
|
298
|
+
if (source === "mail") return MAIL_TCC_GUIDANCE;
|
|
299
|
+
if (source === "messages") return MESSAGES_TCC_GUIDANCE;
|
|
176
300
|
return TCC_GUIDANCE;
|
|
177
301
|
}
|
|
178
302
|
|
|
@@ -184,9 +308,9 @@ export function tccGuidanceFor(source) {
|
|
|
184
308
|
* @returns {{ ok: boolean, output: string, error: string|null, kind: string|null }}
|
|
185
309
|
*/
|
|
186
310
|
export function runAppleScript(script, options = {}) {
|
|
187
|
-
const { timeout = DEFAULT_SCRIPT_TIMEOUT_MS, appName = null } = options;
|
|
311
|
+
const { timeout = DEFAULT_SCRIPT_TIMEOUT_MS, appName = null, language = null } = options;
|
|
188
312
|
try {
|
|
189
|
-
const output = safeOsascript(script, { timeout });
|
|
313
|
+
const output = safeOsascript(script, { timeout, language });
|
|
190
314
|
return { ok: true, output: (output || "").trim(), error: null, kind: null };
|
|
191
315
|
} catch (e) {
|
|
192
316
|
const message = e && e.message ? e.message : String(e);
|