things-api 0.18.0 → 0.19.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.
Files changed (224) hide show
  1. package/README.md +18 -9
  2. package/deputy/AppIcon.icns +0 -0
  3. package/deputy/VERSION +1 -1
  4. package/deputy/helpers-Info.plist +4 -0
  5. package/deputy/prebuilt/Things API Helper.app/Contents/CodeResources +0 -0
  6. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Info.plist +3 -1
  7. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/MacOS/things-reader +0 -0
  8. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/Resources/AppIcon.icns +0 -0
  9. package/deputy/prebuilt/Things API Helper.app/Contents/Helpers/things-reader.app/Contents/_CodeSignature/CodeResources +15 -2
  10. package/deputy/prebuilt/Things API Helper.app/Contents/Info.plist +5 -1
  11. package/deputy/prebuilt/Things API Helper.app/Contents/MacOS/things-deputy +0 -0
  12. package/deputy/prebuilt/Things API Helper.app/Contents/Resources/AppIcon.icns +0 -0
  13. package/deputy/prebuilt/Things API Helper.app/Contents/_CodeSignature/CodeResources +14 -2
  14. package/deputy/reader/Info.plist +2 -0
  15. package/deputy/reader/entitlements.plist +30 -0
  16. package/deputy/reader/main.swift +129 -40
  17. package/deputy/src/server.swift +25 -2
  18. package/deputy/src/tcc.swift +58 -0
  19. package/dist/audit/schema.d.ts +30 -1
  20. package/dist/audit/schema.js.map +1 -1
  21. package/dist/capability.d.ts +322 -0
  22. package/dist/capability.js +706 -0
  23. package/dist/capability.js.map +1 -0
  24. package/dist/cli/commands/doctor.d.ts +13 -1
  25. package/dist/cli/commands/doctor.js +141 -5
  26. package/dist/cli/commands/doctor.js.map +1 -1
  27. package/dist/cli/commands/helpers.js +189 -51
  28. package/dist/cli/commands/helpers.js.map +1 -1
  29. package/dist/cli/commands/mcp.js +8 -0
  30. package/dist/cli/commands/mcp.js.map +1 -1
  31. package/dist/cli/commands/op-result.js +6 -0
  32. package/dist/cli/commands/op-result.js.map +1 -1
  33. package/dist/cli/commands/reads.js +47 -1
  34. package/dist/cli/commands/reads.js.map +1 -1
  35. package/dist/cli/commands/repeat-flags.js +34 -2
  36. package/dist/cli/commands/repeat-flags.js.map +1 -1
  37. package/dist/cli/commands/setup.d.ts +9 -0
  38. package/dist/cli/commands/setup.js +76 -83
  39. package/dist/cli/commands/setup.js.map +1 -1
  40. package/dist/cli/commands/writes.d.ts +1 -1
  41. package/dist/cli/commands/writes.js +311 -142
  42. package/dist/cli/commands/writes.js.map +1 -1
  43. package/dist/cli/dry-run.d.ts +1 -1
  44. package/dist/cli/dry-run.js +1 -1
  45. package/dist/cli/glyphs.d.ts +23 -4
  46. package/dist/cli/glyphs.js +46 -19
  47. package/dist/cli/glyphs.js.map +1 -1
  48. package/dist/cli/help.d.ts +1 -1
  49. package/dist/cli/help.js +49 -27
  50. package/dist/cli/help.js.map +1 -1
  51. package/dist/cli/interrupt.d.ts +54 -6
  52. package/dist/cli/interrupt.js +78 -12
  53. package/dist/cli/interrupt.js.map +1 -1
  54. package/dist/cli/main.js +8 -5
  55. package/dist/cli/main.js.map +1 -1
  56. package/dist/cli/period.d.ts +3 -1
  57. package/dist/cli/period.js +1 -1
  58. package/dist/cli/period.js.map +1 -1
  59. package/dist/cli/read-driver.js +26 -1
  60. package/dist/cli/read-driver.js.map +1 -1
  61. package/dist/cli/render.d.ts +31 -4
  62. package/dist/cli/render.js +127 -15
  63. package/dist/cli/render.js.map +1 -1
  64. package/dist/client.d.ts +23 -3
  65. package/dist/client.js +36 -13
  66. package/dist/client.js.map +1 -1
  67. package/dist/contracts.d.ts +8 -4
  68. package/dist/contracts.js +1 -1
  69. package/dist/contracts.js.map +1 -1
  70. package/dist/deputy/install.d.ts +234 -6
  71. package/dist/deputy/install.js +843 -31
  72. package/dist/deputy/install.js.map +1 -1
  73. package/dist/deputy/notices.js +2 -2
  74. package/dist/deputy/notices.js.map +1 -1
  75. package/dist/deputy/protocol.d.ts +59 -5
  76. package/dist/deputy/protocol.js +48 -11
  77. package/dist/deputy/protocol.js.map +1 -1
  78. package/dist/deputy/routing.d.ts +37 -0
  79. package/dist/deputy/routing.js +181 -27
  80. package/dist/deputy/routing.js.map +1 -1
  81. package/dist/deputy/wake.d.ts +61 -0
  82. package/dist/deputy/wake.js +149 -0
  83. package/dist/deputy/wake.js.map +1 -0
  84. package/dist/diagnose.d.ts +19 -4
  85. package/dist/diagnose.js +64 -8
  86. package/dist/diagnose.js.map +1 -1
  87. package/dist/direct-setup.d.ts +104 -0
  88. package/dist/direct-setup.js +658 -0
  89. package/dist/direct-setup.js.map +1 -0
  90. package/dist/host-access.d.ts +75 -0
  91. package/dist/host-access.js +177 -0
  92. package/dist/host-access.js.map +1 -0
  93. package/dist/index.d.ts +20 -5
  94. package/dist/index.js +14 -1
  95. package/dist/index.js.map +1 -1
  96. package/dist/mcp/server.d.ts +27 -1
  97. package/dist/mcp/server.js +308 -109
  98. package/dist/mcp/server.js.map +1 -1
  99. package/dist/op-result.d.ts +9 -0
  100. package/dist/op-result.js +13 -1
  101. package/dist/op-result.js.map +1 -1
  102. package/dist/read/filter-contract.d.ts +1 -1
  103. package/dist/read/filter-contract.js +4 -0
  104. package/dist/read/filter-contract.js.map +1 -1
  105. package/dist/read/shape.js +4 -0
  106. package/dist/read/shape.js.map +1 -1
  107. package/dist/read/views.d.ts +29 -0
  108. package/dist/read/views.js +60 -0
  109. package/dist/read/views.js.map +1 -1
  110. package/dist/session-grant.d.ts +48 -0
  111. package/dist/session-grant.js +185 -0
  112. package/dist/session-grant.js.map +1 -0
  113. package/dist/surface-copy.d.ts +19 -3
  114. package/dist/surface-copy.js +19 -3
  115. package/dist/surface-copy.js.map +1 -1
  116. package/dist/wizard.d.ts +58 -0
  117. package/dist/wizard.js +174 -0
  118. package/dist/wizard.js.map +1 -0
  119. package/dist/write/availability.d.ts +1 -16
  120. package/dist/write/availability.js +10 -65
  121. package/dist/write/availability.js.map +1 -1
  122. package/dist/write/batch.d.ts +20 -0
  123. package/dist/write/batch.js +111 -21
  124. package/dist/write/batch.js.map +1 -1
  125. package/dist/write/capabilities.d.ts +9 -0
  126. package/dist/write/capabilities.js +2 -0
  127. package/dist/write/capabilities.js.map +1 -1
  128. package/dist/write/clear-reminder.js +1 -1
  129. package/dist/write/clear-reminder.js.map +1 -1
  130. package/dist/write/commands.d.ts +22 -7
  131. package/dist/write/commands.js +231 -254
  132. package/dist/write/commands.js.map +1 -1
  133. package/dist/write/failure-hints.d.ts +32 -7
  134. package/dist/write/failure-hints.js +9 -14
  135. package/dist/write/failure-hints.js.map +1 -1
  136. package/dist/write/guards.js +22 -4
  137. package/dist/write/guards.js.map +1 -1
  138. package/dist/write/lock.d.ts +16 -13
  139. package/dist/write/lock.js +55 -0
  140. package/dist/write/lock.js.map +1 -1
  141. package/dist/write/make-repeating-project.js +9 -13
  142. package/dist/write/make-repeating-project.js.map +1 -1
  143. package/dist/write/move.js +9 -12
  144. package/dist/write/move.js.map +1 -1
  145. package/dist/write/operations.d.ts +51 -29
  146. package/dist/write/operations.js +16 -0
  147. package/dist/write/operations.js.map +1 -1
  148. package/dist/write/opid.d.ts +72 -14
  149. package/dist/write/opid.js +152 -8
  150. package/dist/write/opid.js.map +1 -1
  151. package/dist/write/param-schema.d.ts +80 -0
  152. package/dist/write/param-schema.js +754 -0
  153. package/dist/write/param-schema.js.map +1 -0
  154. package/dist/write/pipeline.d.ts +110 -5
  155. package/dist/write/pipeline.js +288 -14
  156. package/dist/write/pipeline.js.map +1 -1
  157. package/dist/write/pre-state.d.ts +38 -16
  158. package/dist/write/pre-state.js +117 -22
  159. package/dist/write/pre-state.js.map +1 -1
  160. package/dist/write/promote-clone.js +658 -444
  161. package/dist/write/promote-clone.js.map +1 -1
  162. package/dist/write/reorder.js +25 -8
  163. package/dist/write/reorder.js.map +1 -1
  164. package/dist/write/repeat-collateral.d.ts +31 -0
  165. package/dist/write/repeat-collateral.js +153 -0
  166. package/dist/write/repeat-collateral.js.map +1 -0
  167. package/dist/write/repeat-rule.d.ts +28 -1
  168. package/dist/write/repeat-rule.js +54 -4
  169. package/dist/write/repeat-rule.js.map +1 -1
  170. package/dist/write/resolution-timestamps.d.ts +11 -0
  171. package/dist/write/resolution-timestamps.js +108 -40
  172. package/dist/write/resolution-timestamps.js.map +1 -1
  173. package/dist/write/reversibility.js +4 -0
  174. package/dist/write/reversibility.js.map +1 -1
  175. package/dist/write/template-mutation.d.ts +137 -0
  176. package/dist/write/template-mutation.js +598 -0
  177. package/dist/write/template-mutation.js.map +1 -0
  178. package/dist/write/undo.js +10 -20
  179. package/dist/write/undo.js.map +1 -1
  180. package/dist/write/update-fields.d.ts +151 -0
  181. package/dist/write/update-fields.js +427 -0
  182. package/dist/write/update-fields.js.map +1 -0
  183. package/dist/write/vectors/applescript.js +6 -10
  184. package/dist/write/vectors/applescript.js.map +1 -1
  185. package/dist/write/vectors/registry.d.ts +1 -1
  186. package/dist/write/vectors/registry.js +10 -2
  187. package/dist/write/vectors/registry.js.map +1 -1
  188. package/dist/write/vectors/shortcuts.js +2 -2
  189. package/dist/write/vectors/shortcuts.js.map +1 -1
  190. package/dist/write/vectors/simulator.js +15 -4
  191. package/dist/write/vectors/simulator.js.map +1 -1
  192. package/dist/write/vectors/types.d.ts +268 -1
  193. package/dist/write/vectors/ui-certification.js +63 -13
  194. package/dist/write/vectors/ui-certification.js.map +1 -1
  195. package/dist/write/vectors/ui-chord.d.ts +144 -0
  196. package/dist/write/vectors/ui-chord.js +428 -0
  197. package/dist/write/vectors/ui-chord.js.map +1 -0
  198. package/dist/write/vectors/ui-drag.d.ts +8 -4
  199. package/dist/write/vectors/ui-drag.js +2 -0
  200. package/dist/write/vectors/ui-drag.js.map +1 -1
  201. package/dist/write/vectors/ui-recipes.d.ts +70 -3
  202. package/dist/write/vectors/ui-recipes.js +482 -44
  203. package/dist/write/vectors/ui-recipes.js.map +1 -1
  204. package/dist/write/vectors/ui.d.ts +280 -10
  205. package/dist/write/vectors/ui.js +1068 -77
  206. package/dist/write/vectors/ui.js.map +1 -1
  207. package/dist/write/vectors/url-scheme.js +3 -0
  208. package/dist/write/vectors/url-scheme.js.map +1 -1
  209. package/dist/write/verify/delta.d.ts +62 -0
  210. package/dist/write/verify/delta.js +36 -2
  211. package/dist/write/verify/delta.js.map +1 -1
  212. package/dist/write/verify/poller.d.ts +4 -1
  213. package/dist/write/verify/poller.js +14 -0
  214. package/dist/write/verify/poller.js.map +1 -1
  215. package/package.json +2 -1
  216. package/schema/envelope.schema.json +6 -4
  217. package/scripts/build-helpers.sh +5 -0
  218. package/skills/things-cli/SKILL.md +5 -3
  219. package/skills/things-cli/references/contracts.md +1 -0
  220. package/skills/things-cli/references/data-model.md +3 -0
  221. package/skills/things-cli/references/errors.md +1 -1
  222. package/skills/things-cli/references/gui.md +1 -0
  223. package/skills/things-cli/references/repeating.md +6 -0
  224. package/skills/things-cli/references/tag-cleanup.md +64 -0
@@ -13,18 +13,38 @@
13
13
  * bookmark grant and must never change.
14
14
  */
15
15
  import { execFileSync, spawnSync } from "node:child_process";
16
- import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
16
+ import { randomBytes } from "node:crypto";
17
+ import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
17
18
  import { homedir } from "node:os";
18
19
  import { dirname, join } from "node:path";
19
20
  import { fileURLToPath } from "node:url";
21
+ import { loadConfig, saveConfigKey } from "../config.js";
20
22
  import { THINGS_GROUP_CONTAINER } from "../db/locate.js";
23
+ import { readRendezvousToken, rendezvousExists } from "../host-access.js";
24
+ import { createWizard, withDefaultInterrupts } from "../wizard.js";
25
+ import { EXPECTED_PROXIES } from "../write/availability.js";
21
26
  import { DeputySyncBridge } from "./bridge.js";
22
- import { DEPUTY_LAUNCHD_LABEL, HELPERS_BUNDLE_ID, READER_LAUNCHD_LABEL, DEPUTY_PROTOCOL_VERSION, deputyInstalledBinaryPath, deputySocketPath, deputyStateDir, deputyTokenPath, helpersInstallDir, helpersInstalledBundlePath, readerInstalledAppPath, readerSocketPath, readerTokenPath, } from "./protocol.js";
23
- export function deputyPlistPath() {
24
- return join(homedir(), "Library/LaunchAgents", `${DEPUTY_LAUNCHD_LABEL}.plist`);
27
+ import { DEPUTY_LAUNCHD_LABEL, HELPERS_BUNDLE_ID, READER_LAUNCHD_LABEL, DEPUTY_PROTOCOL_VERSION, DeputyRequestError, deputyInstalledBinaryPath, deputySocketPath, deputyStateDir, deputyTokenPath, EXPECTED_HELPERS_VERSION, helpersInstallDir, helpersInstalledBundlePath, readerInstalledAppPath, readerRendezvousDir, readerSandboxContainerDir, readerSocketPath, READER_SOCKET_ENV, readerTokenPath, READER_TOKEN_ENV, } from "./protocol.js";
28
+ import { wakeSystemEvents, wakeThings } from "./wake.js";
29
+ /**
30
+ * Where the LaunchAgent plists live. THINGS_API_LAUNCH_AGENTS_DIR overrides
31
+ * for tests — WITHOUT it, every suite that exercises install/uninstall/reset
32
+ * mutates the DEVELOPER MACHINE's real ~/Library/LaunchAgents. That is not
33
+ * hypothetical: it deleted the live helpers' plists mid-`npm run check`
34
+ * twice on 2026-08-24 before this seam existed. Test setup must always set
35
+ * it alongside THINGS_API_STATE_DIR.
36
+ */
37
+ export function launchAgentsDir(env = process.env) {
38
+ const explicit = env["THINGS_API_LAUNCH_AGENTS_DIR"];
39
+ if (explicit !== undefined && explicit !== "")
40
+ return explicit;
41
+ return join(homedir(), "Library/LaunchAgents");
25
42
  }
26
- export function readerPlistPath() {
27
- return join(homedir(), "Library/LaunchAgents", `${READER_LAUNCHD_LABEL}.plist`);
43
+ export function deputyPlistPath(env = process.env) {
44
+ return join(launchAgentsDir(env), `${DEPUTY_LAUNCHD_LABEL}.plist`);
45
+ }
46
+ export function readerPlistPath(env = process.env) {
47
+ return join(launchAgentsDir(env), `${READER_LAUNCHD_LABEL}.plist`);
28
48
  }
29
49
  /**
30
50
  * Where an install looks for the bundle when the caller names no path, in
@@ -74,7 +94,23 @@ function readerLaunchTarget() {
74
94
  function readerExecPath(appPath) {
75
95
  return join(appPath, "Contents/MacOS/things-reader");
76
96
  }
77
- function renderReaderPlist(appPath) {
97
+ /**
98
+ * The reader's LaunchAgent. Two keys carry the whole host-universal rendezvous:
99
+ *
100
+ * - **`Sockets`** — launchd creates, binds, listens on and chmods the socket at
101
+ * `socketPath`, which is OUR state directory, and hands the sandboxed reader
102
+ * the already-listening fd at activation (`launch_activate_socket`). The
103
+ * reader cannot bind outside its container, so launchd binding on its behalf
104
+ * is the only way the rendezvous escapes it — and with the socket outside
105
+ * every container, a client reaching it crosses no consent class.
106
+ * - **`EnvironmentVariables`** — the access token, minted by the installer and
107
+ * written to a sibling file for clients. Delivering it through the plist is
108
+ * what lets the reader keep its expected token out of the container too.
109
+ *
110
+ * The plist therefore CARRIES A SECRET and is written 0600 — the same
111
+ * same-user trust class as the token file itself.
112
+ */
113
+ export function renderReaderPlist(appPath, socketPath, token) {
78
114
  return `<?xml version="1.0" encoding="UTF-8"?>
79
115
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
80
116
  <plist version="1.0">
@@ -90,6 +126,21 @@ function renderReaderPlist(appPath) {
90
126
  <true/>
91
127
  <key>KeepAlive</key>
92
128
  <true/>
129
+ <key>StandardErrorPath</key>
130
+ <string>${dirname(socketPath)}/reader.stderr.log</string>
131
+ <!-- The rendezvous path and access token, minted/chosen by install. The
132
+ reader BINDS the socket itself at this host-neutral path — its sandbox
133
+ entitlement covers exactly this directory. launchd Sockets activation
134
+ is NOT usable: launch_activate_socket fails with 159 "Sandbox
135
+ restriction" inside the App Sandbox (measured 2026-08-24). This file is
136
+ written 0600 because of the token. -->
137
+ <key>EnvironmentVariables</key>
138
+ <dict>
139
+ <key>${READER_SOCKET_ENV}</key>
140
+ <string>${socketPath}</string>
141
+ <key>${READER_TOKEN_ENV}</key>
142
+ <string>${token}</string>
143
+ </dict>
93
144
  <!-- Background Task Management groups the login item under the helper
94
145
  bundle's display name ("Things API Helper") instead of the signing
95
146
  certificate's personal name. -->
@@ -101,10 +152,52 @@ function renderReaderPlist(appPath) {
101
152
  </plist>
102
153
  `;
103
154
  }
155
+ /**
156
+ * Mint the reader's access token and lay down the client half of the
157
+ * rendezvous: `<state>/reader`, 0700, holding a 0600 token file. Returns the
158
+ * token so the same value can be injected into the LaunchAgent.
159
+ *
160
+ * The installer mints it — not the reader — because the reader can only write
161
+ * inside its container, which is the placement this whole arrangement exists
162
+ * to leave. A fresh token per install is deliberate: install owns the pair
163
+ * wholesale, and rotating the secret costs nothing when both ends are rewritten
164
+ * in the same breath.
165
+ */
166
+ export function mintReaderRendezvous(env) {
167
+ const dir = readerRendezvousDir(env);
168
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
169
+ chmodSync(dir, 0o700);
170
+ const token = randomBytes(32).toString("hex");
171
+ const tokenPath = readerTokenPath(env);
172
+ writeFileSync(tokenPath, token, { mode: 0o600 });
173
+ // writeFileSync's mode applies on CREATE only — an existing file keeps
174
+ // whatever it had, so the chmod is the one that actually holds the line.
175
+ chmodSync(tokenPath, 0o600);
176
+ // launchd binds the socket itself and refuses when something already sits at
177
+ // the path (a plain file left by an older layout, or a socket from a job it
178
+ // no longer tracks). Clearing it is safe: we booted the reader out above.
179
+ try {
180
+ unlinkSync(readerSocketPath(env));
181
+ }
182
+ catch {
183
+ // ENOENT is the ordinary case.
184
+ }
185
+ return token;
186
+ }
104
187
  function launchTarget() {
105
188
  return `gui/${process.getuid?.() ?? 501}/${DEPUTY_LAUNCHD_LABEL}`;
106
189
  }
107
190
  function launchctl(args) {
191
+ // The other half of the test blast-shield: with the LaunchAgents dir
192
+ // redirected, real launchctl calls would still act on the REAL jobs while
193
+ // the plists are fakes — tests booted the live helpers out mid-check on
194
+ // 2026-08-24 exactly this way. Under the override, launchctl is inert.
195
+ if ((process.env["THINGS_API_LAUNCH_AGENTS_DIR"] ?? "") !== "") {
196
+ return {
197
+ ok: false,
198
+ output: "launchctl disabled under THINGS_API_LAUNCH_AGENTS_DIR (test shield)",
199
+ };
200
+ }
108
201
  try {
109
202
  // stderr must be captured, never inherited: a routine negative probe
110
203
  // ("Could not find service … in domain") is a state we REPORT, not noise
@@ -181,9 +274,11 @@ export function deputySigningInfo(binaryPath) {
181
274
  function readerHelloProbe(env) {
182
275
  const socketPath = readerSocketPath(env);
183
276
  const tokenPath = readerTokenPath(env);
184
- if (!existsSync(socketPath) || !existsSync(tokenPath))
277
+ if (!rendezvousExists(socketPath) || !rendezvousExists(tokenPath))
278
+ return null;
279
+ const token = readRendezvousToken(tokenPath);
280
+ if (token === null)
185
281
  return null;
186
- const token = readFileSync(tokenPath, "utf8").trim();
187
282
  const bridge = new DeputySyncBridge(socketPath);
188
283
  try {
189
284
  const res = bridge.request({ v: DEPUTY_PROTOCOL_VERSION, token, verb: "hello" }, 2000);
@@ -222,7 +317,7 @@ export function installHelpers(options = {}, env = process.env) {
222
317
  rmSync(installDir, { recursive: true, force: true });
223
318
  mkdirSync(installDir, { recursive: true, mode: 0o700 });
224
319
  cpSync(source, bundlePath, { recursive: true });
225
- const plistPath = deputyPlistPath();
320
+ const plistPath = deputyPlistPath(env);
226
321
  mkdirSync(dirname(plistPath), { recursive: true });
227
322
  writeFileSync(plistPath, renderPlist(deputyInstalledBinaryPath(env), stateDir));
228
323
  const warnings = [];
@@ -237,8 +332,12 @@ export function installHelpers(options = {}, env = process.env) {
237
332
  }
238
333
  let readerGranted = null;
239
334
  if (readerInBuild) {
240
- const readerPlist = readerPlistPath();
241
- writeFileSync(readerPlist, renderReaderPlist(readerInstalledAppPath(env)));
335
+ // The rendezvous is minted BEFORE the plist is written: the plist carries
336
+ // the same token, so the two must be laid down from one value.
337
+ const readerToken = mintReaderRendezvous(env);
338
+ const readerPlist = readerPlistPath(env);
339
+ writeFileSync(readerPlist, renderReaderPlist(readerInstalledAppPath(env), readerSocketPath(env), readerToken), { mode: 0o600 });
340
+ chmodSync(readerPlist, 0o600);
242
341
  const readerBoot = launchctl(["bootstrap", `gui/${process.getuid?.() ?? 501}`, readerPlist]);
243
342
  if (!readerBoot.ok) {
244
343
  warnings.push(`reader launchctl bootstrap failed: ${readerBoot.output.trim()}`);
@@ -258,7 +357,7 @@ export function installHelpers(options = {}, env = process.env) {
258
357
  }
259
358
  }
260
359
  else {
261
- warnings.push("the bundle was built without things-reader (no Apple-issued signing identity?) — file reads run direct. Build with an Apple-chain identity, reinstall, then run `things helpers grant`.");
360
+ warnings.push("the bundle was built without things-reader (no Apple-issued signing identity?) — file reads run direct. Build with an Apple-chain identity, reinstall, then run `things helpers setup`.");
262
361
  }
263
362
  return {
264
363
  bundlePath,
@@ -270,12 +369,116 @@ export function installHelpers(options = {}, env = process.env) {
270
369
  warnings,
271
370
  };
272
371
  }
273
- /** Stop both halves and remove LaunchAgents + the installed bundle (state — tokens, logs, the reader's grant — is kept). */
274
- export function uninstallHelpers(env = process.env) {
372
+ /**
373
+ * The LaunchServices registration tool. `tccutil` addresses grants by BUNDLE
374
+ * IDENTIFIER and resolves that identifier through LaunchServices, so on a
375
+ * machine where the bundle is already gone it refuses with -10814 and the
376
+ * dormant grant rows stay put. Handing LaunchServices the PACKAGED bundle
377
+ * (`lsregister -f -R "<bundle>"`) makes both identities — the helper and the
378
+ * reader nested inside it — resolvable again, after which the resets succeed.
379
+ * Measured on a live host, 2026-08-24.
380
+ */
381
+ const LSREGISTER = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
382
+ function runToolDefault(bin, args) {
383
+ try {
384
+ const output = execFileSync(bin, args, {
385
+ encoding: "utf8",
386
+ stdio: ["ignore", "pipe", "pipe"],
387
+ timeout: 30_000,
388
+ });
389
+ return { ok: true, output };
390
+ }
391
+ catch (err) {
392
+ const e = err;
393
+ return { ok: false, output: `${e.stdout ?? ""}${e.stderr ?? ""}` || (e.message ?? "failed") };
394
+ }
395
+ }
396
+ /**
397
+ * Revoke both helper identities' TCC grants (`tccutil reset All` per bundle
398
+ * identity — Automation, Accessibility, and every other class keyed to them).
399
+ *
400
+ * Called BEFORE anything is torn down, because `tccutil` resolves the
401
+ * identifier through LaunchServices and refuses with -10814
402
+ * (kLSApplicationNotFoundErr) once no app on disk carries it. When the
403
+ * installed bundle is already gone, the packaged one is registered first
404
+ * ({@link LSREGISTER}) and left registered — the file legitimately exists, so
405
+ * un-registering it would be a lie about the machine. With no bundle anywhere
406
+ * the resets still run (LaunchServices may hold an older registration) and a
407
+ * -10814 is reported as the honest limit it is.
408
+ */
409
+ function revokeGrants(env, deps, warnings) {
410
+ const runTool = deps.runTool ?? runToolDefault;
411
+ const packagedBundlePath = deps.packagedBundlePath ?? helpersDefaultBuildPath;
412
+ let resolvedVia = "installed";
413
+ let registeredBundle = null;
414
+ if (!existsSync(deputyInstalledBinaryPath(env))) {
415
+ const packaged = packagedBundlePath();
416
+ if (packaged === null) {
417
+ resolvedVia = "none";
418
+ }
419
+ else {
420
+ const res = runTool(LSREGISTER, ["-f", "-R", packaged]);
421
+ if (res.ok) {
422
+ resolvedVia = "packaged";
423
+ registeredBundle = packaged;
424
+ }
425
+ else {
426
+ resolvedVia = "none";
427
+ warnings.push(`could not register ${packaged} with LaunchServices: ${res.output.trim() || "unknown"}`);
428
+ }
429
+ }
430
+ }
431
+ const tccResets = [];
432
+ for (const target of [HELPERS_BUNDLE_ID, READER_LAUNCHD_LABEL]) {
433
+ const res = runTool("/usr/bin/tccutil", ["reset", "All", target]);
434
+ const noApp = !res.ok && /No such bundle identifier|-10814/.test(res.output);
435
+ tccResets.push({
436
+ target,
437
+ ok: res.ok || noApp,
438
+ detail: res.ok
439
+ ? res.output.trim() || "reset"
440
+ : noApp
441
+ ? resolvedVia === "none"
442
+ ? "no app carries this identifier and none is packaged here — reinstall the helpers, or clear the grants in System Settings ▸ Privacy & Security"
443
+ : "no app registered under this identifier — nothing to revoke"
444
+ : res.output.trim() || "failed",
445
+ });
446
+ if (!res.ok && !noApp) {
447
+ warnings.push(`tccutil reset All ${target} failed: ${res.output.trim() || "unknown"}`);
448
+ }
449
+ }
450
+ return {
451
+ tccResets,
452
+ resolvedVia,
453
+ registeredBundle,
454
+ shortcutsNote: "the bundled things-proxy-* shortcuts cannot be removed by any tool — delete them by hand in Shortcuts.app if a truly fresh machine is wanted (`things setup` re-imports them)",
455
+ };
456
+ }
457
+ /**
458
+ * Stop both halves and remove their LaunchAgents + the installed bundle.
459
+ *
460
+ * By default the macOS grants and the local state (tokens, logs, the reader's
461
+ * bookmark) are KEPT: the TCC rows are keyed to the two signing identities and
462
+ * simply go dormant, so a later reinstall picks them straight back up with no
463
+ * second ceremony. `revoke` turns this into the ceremony's full inverse —
464
+ * grants revoked first (see {@link revokeGrants}), then the uninstall, then the
465
+ * reader's container and the deputy's state dir deleted (the read grant is a
466
+ * bookmark FILE, not a TCC row, so revoking it means deleting it).
467
+ *
468
+ * Every leg is independent and IDEMPOTENT: an already-uninstalled helper, an
469
+ * empty permission store, and absent directories are all fine — each leg does
470
+ * whatever is still outstanding, so this works from any partial state and a
471
+ * second run is an all-no-op.
472
+ */
473
+ export function uninstallHelpers(options = {}, env = process.env, deps = {}) {
474
+ const warnings = [];
475
+ const revoke = options.revoke === true;
476
+ // Revocation runs while the installed bundle (if any) still resolves.
477
+ const revocation = revoke ? revokeGrants(env, deps, warnings) : null;
275
478
  const removed = [];
276
479
  launchctl(["bootout", launchTarget()]);
277
480
  launchctl(["bootout", readerLaunchTarget()]);
278
- for (const path of [deputyPlistPath(), readerPlistPath()]) {
481
+ for (const path of [deputyPlistPath(env), readerPlistPath(env)]) {
279
482
  if (existsSync(path)) {
280
483
  rmSync(path);
281
484
  removed.push(path);
@@ -286,13 +489,32 @@ export function uninstallHelpers(env = process.env) {
286
489
  rmSync(installDir, { recursive: true });
287
490
  removed.push(installDir);
288
491
  }
289
- return { removed };
492
+ if (revoke) {
493
+ // The reader's container holds the bookmark that IS the read grant; the
494
+ // rendezvous holds the access token; the deputy's state dir holds its own.
495
+ for (const dir of [
496
+ readerSandboxContainerDir(env),
497
+ readerRendezvousDir(env),
498
+ deputyStateDir(env),
499
+ ]) {
500
+ if (existsSync(dir)) {
501
+ try {
502
+ rmSync(dir, { recursive: true, force: true });
503
+ removed.push(dir);
504
+ }
505
+ catch (err) {
506
+ warnings.push(`could not remove ${dir}: ${err instanceof Error ? err.message : String(err)}`);
507
+ }
508
+ }
509
+ }
510
+ }
511
+ return { removed, revocation, warnings };
290
512
  }
291
513
  /** Restart the launchd-managed helpers (picks up a rebuilt installed bundle). */
292
514
  export function restartHelpers() {
293
515
  const res = launchctl(["kickstart", "-k", launchTarget()]);
294
516
  if (!res.ok) {
295
- throw new Error(`launchctl kickstart failed (${res.output.trim() || "unknown"}) — are the helpers installed? Run: things helpers install`);
517
+ throw new Error(`launchctl kickstart failed (${res.output.trim() || "unknown"}) — are the helpers installed? Run: things helpers setup`);
296
518
  }
297
519
  // Reader restart is best-effort: it may legitimately not be installed.
298
520
  launchctl(["kickstart", "-k", readerLaunchTarget()]);
@@ -311,7 +533,7 @@ export function grantReader(env = process.env) {
311
533
  if (!existsSync(appPath)) {
312
534
  return {
313
535
  granted: false,
314
- detail: "things-reader is not installed — run `things helpers install` first",
536
+ detail: "things-reader is not installed — run `things helpers setup` first",
315
537
  };
316
538
  }
317
539
  const thingsContainer = join(homedir(), THINGS_GROUP_CONTAINER);
@@ -336,8 +558,8 @@ export function grantReader(env = process.env) {
336
558
  const deadline = Date.now() + 15_000;
337
559
  let detail = "the reader is not running — `things helpers status`";
338
560
  while (Date.now() < deadline) {
339
- if (existsSync(socketPath) && existsSync(tokenPath)) {
340
- const token = readFileSync(tokenPath, "utf8").trim();
561
+ const token = rendezvousExists(socketPath) ? readRendezvousToken(tokenPath) : null;
562
+ if (token !== null) {
341
563
  const bridge = new DeputySyncBridge(socketPath);
342
564
  try {
343
565
  const res = bridge.request({ v: DEPUTY_PROTOCOL_VERSION, token, verb: "hello" }, 2000);
@@ -348,10 +570,10 @@ export function grantReader(env = process.env) {
348
570
  return { granted: true, detail: "granted" };
349
571
  return {
350
572
  granted: false,
351
- detail: "the grant landed, but the Things database was not found inside the granted folder — rerun `things helpers grant` and grant the Things data folder",
573
+ detail: "the grant landed, but the Things database was not found inside the granted folder — rerun `things helpers setup` and grant the Things data folder",
352
574
  };
353
575
  }
354
- detail = "the panel closed but no grant landed (canceled?) — rerun `things helpers grant`";
576
+ detail = "the panel closed but no grant landed (canceled?) — rerun `things helpers setup`";
355
577
  }
356
578
  catch {
357
579
  detail = "the reader socket is not answering — `things helpers status`";
@@ -367,6 +589,591 @@ export function grantReader(env = process.env) {
367
589
  function syncSleepMs(ms) {
368
590
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
369
591
  }
592
+ // ---------------------------------------------------------------------------
593
+ // The onboarding ceremony (docs/design/helpers-onboarding.md)
594
+ // ---------------------------------------------------------------------------
595
+ /** How long a consent dialog may stay unanswered before the leg is left pending. */
596
+ const AUTOMATION_PROMPT_TIMEOUT_MS = 120_000;
597
+ /** How long the Accessibility toggle is waited for, and how often it is re-read. */
598
+ const AX_WAIT_TIMEOUT_MS = 120_000;
599
+ const AX_POLL_INTERVAL_MS = 2000;
600
+ /** The deputy kills its child at timeoutMs; the client deadline adds grace. */
601
+ const CLIENT_GRACE_MS = 5000;
602
+ /**
603
+ * How long the ceremony waits for the deputy's socket. `setup` installs first,
604
+ * which boots the launchd jobs out and back in, so the socket is legitimately
605
+ * absent for a moment when the ceremony opens its channel.
606
+ */
607
+ const DEPUTY_SOCKET_WAIT_MS = 15_000;
608
+ const AX_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility";
609
+ /** Which tier each leg belongs to. */
610
+ const LEG_TIER = {
611
+ "reader-read-grant": "base",
612
+ "automation-things": "base",
613
+ "automation-system-events": "gui",
614
+ accessibility: "gui",
615
+ shortcuts: "base",
616
+ };
617
+ /** A live sync bridge to the deputy socket, independent of the routing config. */
618
+ function openDeputyChannel(env, waitMs) {
619
+ const socketPath = deputySocketPath(env);
620
+ const tokenPath = deputyTokenPath(env);
621
+ const up = () => existsSync(socketPath) && existsSync(tokenPath);
622
+ const deadline = Date.now() + waitMs;
623
+ while (!up() && Date.now() < deadline)
624
+ syncSleepMs(250);
625
+ if (!up()) {
626
+ throw new Error(`the deputy is not running (no socket at ${socketPath}) — \`things helpers status\` to inspect`);
627
+ }
628
+ const token = readFileSync(tokenPath, "utf8").trim();
629
+ const bridge = new DeputySyncBridge(socketPath);
630
+ const request = (fields, timeoutMs) => {
631
+ const res = bridge.request({ v: DEPUTY_PROTOCOL_VERSION, token, ...fields }, timeoutMs);
632
+ if (res["ok"] === true)
633
+ return res;
634
+ const err = res["error"];
635
+ throw new DeputyRequestError(err?.code ?? "internal", err?.message ?? "the deputy refused");
636
+ };
637
+ return {
638
+ hello: () => request({ verb: "hello" }, 5000),
639
+ request,
640
+ close: () => {
641
+ bridge.close();
642
+ },
643
+ };
644
+ }
645
+ /** Reader handshake + a `locate` inside the granted scope, on one connection. */
646
+ function readerProbeDefault(env) {
647
+ const socketPath = readerSocketPath(env);
648
+ const tokenPath = readerTokenPath(env);
649
+ if (!rendezvousExists(socketPath) || !rendezvousExists(tokenPath))
650
+ return null;
651
+ const token = readRendezvousToken(tokenPath);
652
+ if (token === null)
653
+ return null;
654
+ const bridge = new DeputySyncBridge(socketPath);
655
+ try {
656
+ const res = bridge.request({ v: DEPUTY_PROTOCOL_VERSION, token, verb: "hello" }, 2000);
657
+ if (res["ok"] !== true)
658
+ return null;
659
+ const granted = res.granted === true;
660
+ if (!granted)
661
+ return { granted: false, locates: false };
662
+ const locate = bridge.request({ v: DEPUTY_PROTOCOL_VERSION, token, verb: "locate" }, 5000);
663
+ return { granted: true, locates: locate["ok"] === true };
664
+ }
665
+ catch {
666
+ return null;
667
+ }
668
+ finally {
669
+ bridge.close();
670
+ }
671
+ }
672
+ function firstLine(text) {
673
+ return text.trim().split("\n")[0]?.trim() ?? "";
674
+ }
675
+ /**
676
+ * Both ways out of a denial, always named together. macOS will not show the
677
+ * Automation dialog again once it has been refused, so the choices are the
678
+ * System Settings switch or re-arming the dialog by clearing the recorded
679
+ * refusal. The ceremony NEVER clears a denial itself — that is the user's call.
680
+ */
681
+ function deniedRemediation(settingsName) {
682
+ return (`turn on Things API Helper under System Settings ▸ Privacy & Security ▸ Automation ▸ ${settingsName}, ` +
683
+ `or re-arm the dialog with \`tccutil reset AppleEvents ${HELPERS_BUNDLE_ID}\`, then rerun \`things helpers setup\``);
684
+ }
685
+ /**
686
+ * One Automation leg: skipped when the deputy already reports the target
687
+ * granted, otherwise a benign AppleEvent sent THROUGH the deputy — the request
688
+ * blocks while the consent dialog is up, so answering it right there is what
689
+ * completes the leg. The event auto-launches its target, which is intended:
690
+ * macOS has no consent record to hand out while the target is down.
691
+ *
692
+ * The probe script MUST dispatch a real Apple event. A handful of
693
+ * application-object properties (`version`, `name`, `id`, `running`) are
694
+ * answered locally by the AppleScript runtime from the target's bundle —
695
+ * no event leaves the process, so no consent dialog is raised and no grant
696
+ * is minted, while the script still exits 0. That exact false positive
697
+ * shipped in the first ceremony (probe was `version`; the "granted" leg had
698
+ * granted nothing), which is also why a 0 exit alone is no longer believed:
699
+ * the leg re-reads the deputy's own AEDeterminePermission verdict afterwards
700
+ * and only reports what macOS reports. An old deputy whose hello carries no
701
+ * automation fields cannot be re-read — there the 0 exit stands, best effort.
702
+ */
703
+ function automationLeg(channel, spec, known, recheck, timeoutMs, progress) {
704
+ const base = { leg: spec.leg, label: spec.label, tier: LEG_TIER[spec.leg] };
705
+ if (known === "granted") {
706
+ progress(`${spec.label}: already granted`);
707
+ return { ...base, state: "granted", alreadyGranted: true, detail: "already granted" };
708
+ }
709
+ if (known === "denied") {
710
+ progress(`${spec.label}: denied — the dialog cannot be raised again`);
711
+ return {
712
+ ...base,
713
+ state: "denied",
714
+ alreadyGranted: false,
715
+ detail: deniedRemediation(spec.settingsName),
716
+ };
717
+ }
718
+ progress(`${spec.label}: asking now — answer the dialog if one appears`);
719
+ let res;
720
+ try {
721
+ res = channel.request({ verb: "osascript", script: spec.script, lang: "applescript", timeoutMs }, timeoutMs + CLIENT_GRACE_MS);
722
+ }
723
+ catch (err) {
724
+ const why = err instanceof Error ? err.message : String(err);
725
+ progress(`${spec.label}: no answer (${why})`);
726
+ return { ...base, state: "pending", alreadyGranted: false, detail: why };
727
+ }
728
+ if (res.timedOut === true) {
729
+ progress(`${spec.label}: still waiting on the dialog`);
730
+ return {
731
+ ...base,
732
+ state: "pending",
733
+ alreadyGranted: false,
734
+ detail: `no answer within ${Math.round(timeoutMs / 1000)}s — answer the dialog, then rerun`,
735
+ };
736
+ }
737
+ if (res.exitCode === 0) {
738
+ const after = recheck();
739
+ if (after === undefined || after === "granted") {
740
+ progress(`${spec.label}: granted`);
741
+ return { ...base, state: "granted", alreadyGranted: false, detail: "granted" };
742
+ }
743
+ progress(`${spec.label}: the probe ran but macOS reports no grant (${after})`);
744
+ return {
745
+ ...base,
746
+ state: "pending",
747
+ alreadyGranted: false,
748
+ detail: `probe succeeded yet AEDeterminePermission reports "${after}" — rerun, or turn on Things API Helper under System Settings ▸ Privacy & Security ▸ Automation ▸ ${spec.settingsName}`,
749
+ };
750
+ }
751
+ if (res.stderr.includes("-1743")) {
752
+ progress(`${spec.label}: denied`);
753
+ return {
754
+ ...base,
755
+ state: "denied",
756
+ alreadyGranted: false,
757
+ detail: deniedRemediation(spec.settingsName),
758
+ };
759
+ }
760
+ const why = firstLine(res.stderr) || `exit ${res.exitCode}`;
761
+ progress(`${spec.label}: no grant yet (${why})`);
762
+ return { ...base, state: "pending", alreadyGranted: false, detail: why };
763
+ }
764
+ /**
765
+ * The Accessibility leg. Unlike Automation, the grant is not a dialog answer
766
+ * but a switch in System Settings, so the prompt is fire-and-forget and the
767
+ * ceremony polls the deputy's own trust bit until it flips.
768
+ */
769
+ function accessibilityLeg(channel, axTrusted, deps) {
770
+ const base = { leg: "accessibility", label: "accessibility", tier: "gui" };
771
+ if (axTrusted === true) {
772
+ deps.progress("accessibility: already granted");
773
+ return { ...base, state: "granted", alreadyGranted: true, detail: "already granted" };
774
+ }
775
+ try {
776
+ const res = channel.request({ verb: "prime-ax" }, 10_000);
777
+ if (res["axTrusted"] === true) {
778
+ deps.progress("accessibility: already granted");
779
+ return { ...base, state: "granted", alreadyGranted: true, detail: "already granted" };
780
+ }
781
+ }
782
+ catch (err) {
783
+ const why = err instanceof Error ? err.message : String(err);
784
+ deps.progress(`accessibility: could not raise the prompt (${why})`);
785
+ return { ...base, state: "pending", alreadyGranted: false, detail: why };
786
+ }
787
+ deps.progress('accessibility: System Settings ▸ Privacy & Security ▸ Accessibility — turn on "Things API Helper"');
788
+ deps.openUrl(AX_SETTINGS_URL);
789
+ deps.progress("accessibility: waiting for the toggle — Ctrl-C and rerun anytime");
790
+ const deadline = deps.now() + deps.timeoutMs;
791
+ while (deps.now() < deadline) {
792
+ deps.sleep(deps.intervalMs);
793
+ try {
794
+ if (channel.hello().axTrusted === true) {
795
+ deps.progress("accessibility: granted");
796
+ return { ...base, state: "granted", alreadyGranted: false, detail: "granted" };
797
+ }
798
+ }
799
+ catch {
800
+ // A restart mid-wait (or a momentary hiccup) is not an answer — keep
801
+ // asking until the deadline; the state is whatever the last read said.
802
+ }
803
+ }
804
+ deps.progress("accessibility: not toggled yet");
805
+ return {
806
+ ...base,
807
+ state: "pending",
808
+ alreadyGranted: false,
809
+ detail: `still off after ${Math.round(deps.timeoutMs / 1000)}s — turn on "Things API Helper" under System Settings ▸ Privacy & Security ▸ Accessibility, then rerun`,
810
+ };
811
+ }
812
+ /** The bundled proxy shortcuts, counted through the deputy's `shortcuts list`. */
813
+ function shortcutsLeg(channel, progress) {
814
+ const base = { leg: "shortcuts", label: "shortcuts", tier: "base" };
815
+ let installed;
816
+ try {
817
+ const res = channel.request({ verb: "shortcuts", op: "list", timeoutMs: 20_000 }, 20_000 + CLIENT_GRACE_MS);
818
+ if (res.exitCode !== 0 || res.timedOut === true) {
819
+ const why = firstLine(res.stderr) || `exit ${res.exitCode}`;
820
+ progress(`shortcuts: could not list them (${why})`);
821
+ return { ...base, state: "pending", alreadyGranted: false, detail: why };
822
+ }
823
+ installed = new Set(res.stdout
824
+ .split("\n")
825
+ .map((line) => line.trim())
826
+ .filter((line) => line !== ""));
827
+ }
828
+ catch (err) {
829
+ const why = err instanceof Error ? err.message : String(err);
830
+ progress(`shortcuts: could not list them (${why})`);
831
+ return { ...base, state: "pending", alreadyGranted: false, detail: why };
832
+ }
833
+ const missing = EXPECTED_PROXIES.filter((name) => !installed.has(name));
834
+ if (missing.length === 0) {
835
+ progress(`shortcuts: all ${EXPECTED_PROXIES.length} installed`);
836
+ return {
837
+ ...base,
838
+ state: "granted",
839
+ alreadyGranted: true,
840
+ detail: `all ${EXPECTED_PROXIES.length} installed`,
841
+ };
842
+ }
843
+ progress(`shortcuts: ${missing.length} missing — run \`things setup\``);
844
+ return {
845
+ ...base,
846
+ state: "skipped-not-installed",
847
+ alreadyGranted: false,
848
+ detail: `missing ${missing.join(", ")} — run \`things setup\` (it opens the import screen for each)`,
849
+ };
850
+ }
851
+ function readerStanding(env, readerProbe) {
852
+ if (!existsSync(readerInstalledAppPath(env)))
853
+ return "not-installed";
854
+ const probe = readerProbe();
855
+ return probe !== null && probe.granted && probe.locates ? "granted" : "needs-panel";
856
+ }
857
+ /** The reader's durable read grant — skipped when a database already resolves inside it. */
858
+ function readerLeg(standing, deps) {
859
+ const base = {
860
+ leg: "reader-read-grant",
861
+ label: "reader read grant",
862
+ tier: "base",
863
+ };
864
+ if (standing === "not-installed") {
865
+ deps.progress("reader read grant: the reader is not part of the installed bundle");
866
+ return {
867
+ ...base,
868
+ state: "skipped-not-installed",
869
+ alreadyGranted: false,
870
+ detail: "the bundle was built without things-reader (no Apple-issued signing identity) — database reads run direct",
871
+ };
872
+ }
873
+ if (standing === "granted") {
874
+ deps.progress("reader read grant: already granted");
875
+ return { ...base, state: "granted", alreadyGranted: true, detail: "already granted" };
876
+ }
877
+ deps.progress("reader read grant: accept the folder panel the reader is opening");
878
+ const result = deps.grant();
879
+ if (result.granted) {
880
+ deps.progress("reader read grant: granted");
881
+ return { ...base, state: "granted", alreadyGranted: false, detail: "granted" };
882
+ }
883
+ deps.progress(`reader read grant: not granted (${result.detail})`);
884
+ return { ...base, state: "pending", alreadyGranted: false, detail: result.detail };
885
+ }
886
+ /**
887
+ * The hint the BASE tier closes with (Article V, tiered). A machine that never
888
+ * asked for GUI-driving should still learn that the capability exists and what
889
+ * it buys, exactly once, at the moment it has just finished setting up.
890
+ */
891
+ const GUI_TIER_HINT = "GUI-driving is not set up — some features drive the app window (repeat-rule edits, area " +
892
+ "reorder); run `things helpers setup --gui` to enable.";
893
+ function closingLine(steps, mode, tier) {
894
+ const denied = steps.filter((s) => s.state === "denied");
895
+ if (denied.length > 0) {
896
+ return (`setup did not finish — ${denied.map((s) => s.label).join(" and ")} ${denied.length === 1 ? "is" : "are"} denied. ` +
897
+ `Turn Things API Helper on under System Settings ▸ Privacy & Security ▸ Automation, or re-arm the dialog with ` +
898
+ `\`tccutil reset AppleEvents ${HELPERS_BUNDLE_ID}\`, then rerun \`things helpers setup\`.`);
899
+ }
900
+ const pending = steps.filter((s) => s.state === "pending");
901
+ if (pending.length > 0) {
902
+ return (`setup did not finish — ${pending.map((s) => s.label).join(", ")} still needs you. ` +
903
+ `Rerun \`things helpers setup\` to resume exactly there; everything already granted is skipped.`);
904
+ }
905
+ const shortcutsMissing = steps.some((s) => s.leg === "shortcuts" && s.state === "skipped-not-installed");
906
+ const guiHint = tier === "base" ? ` ${GUI_TIER_HINT}` : "";
907
+ if (mode === "false") {
908
+ return ("everything asked for is granted, but routing is off — `things config set helpers-enabled auto` " +
909
+ `to send reads and writes through the helpers.${guiHint}`);
910
+ }
911
+ const base = "you're done — writes and reads route through the helpers with no further prompts.";
912
+ const shortcuts = shortcutsMissing
913
+ ? " The bundled shortcuts are still missing; run `things setup` if you want that path too."
914
+ : "";
915
+ return `${base}${shortcuts}${guiHint}`;
916
+ }
917
+ /** What each leg puts on screen, for the upfront banner. */
918
+ const PROMPT_LABELS = {
919
+ "reader-read-grant": "the reader's folder panel",
920
+ "automation-things": "app control for Things",
921
+ "automation-system-events": "app control for System Events",
922
+ accessibility: "the Accessibility switch",
923
+ // The census asks the deputy, never the user.
924
+ shortcuts: "",
925
+ };
926
+ /**
927
+ * What the human is about to see, in the words macOS will actually use. The
928
+ * wizard prints these one leg ahead of the dialog (Article V, mode-aware); in
929
+ * strict mode they are never printed and the upfront banner's count stands
930
+ * alone.
931
+ */
932
+ const PROMPT_EXPLAINERS = {
933
+ "reader-read-grant": [
934
+ "Next: read access to your Things data.",
935
+ " A file panel opens, already inside the Things data folder — click Grant Access.",
936
+ " Nothing else in that panel needs changing; do not navigate elsewhere.",
937
+ ],
938
+ "automation-things": [
939
+ "Next: permission for the helper to control Things.",
940
+ ' A macOS dialog will appear: "Things API Helper" wants access to control "Things" —',
941
+ " click Allow. Things opens if it was closed; that is expected.",
942
+ ],
943
+ "automation-system-events": [
944
+ "Next: permission for the helper to control System Events (the GUI-driving tier).",
945
+ ' A macOS dialog will appear: "Things API Helper" wants access to control',
946
+ ' "System Events" — click Allow. System Events is the macOS component that reads and',
947
+ " presses the Things window's own controls.",
948
+ ],
949
+ accessibility: [
950
+ "Next: the Accessibility switch (the GUI-driving tier).",
951
+ " System Settings opens at Privacy & Security ▸ Accessibility — turn on",
952
+ ' "Things API Helper" in that list. This one is a switch you flip, not a dialog you',
953
+ " answer, so setup waits and watches for it.",
954
+ ],
955
+ shortcuts: [],
956
+ };
957
+ /**
958
+ * Which legs are about to put something on screen, surveyed prompt-free from
959
+ * the deputy's handshake and the reader's bookmark state. A leg macOS already
960
+ * records as `denied` raises nothing (the dialog is spent), so it is not
961
+ * counted here even though it will be reported as a failure.
962
+ */
963
+ function willRaiseAutomationDialog(state) {
964
+ return state !== "granted" && state !== "denied";
965
+ }
966
+ function outstandingPrompts(hello, reader, tier,
967
+ /** Things' standing AFTER any wake — never the raw handshake value. */
968
+ automationThings,
969
+ /** System Events' standing AFTER any wake — never the raw handshake value. */
970
+ systemEvents) {
971
+ const outstanding = [];
972
+ if (reader === "needs-panel")
973
+ outstanding.push("reader-read-grant");
974
+ if (willRaiseAutomationDialog(automationThings))
975
+ outstanding.push("automation-things");
976
+ if (tier === "gui") {
977
+ if (willRaiseAutomationDialog(systemEvents)) {
978
+ outstanding.push("automation-system-events");
979
+ }
980
+ if (hello.axTrusted !== true)
981
+ outstanding.push("accessibility");
982
+ }
983
+ return outstanding;
984
+ }
985
+ /** The interactive tier question, asked only when nothing else decided it. */
986
+ const GUI_TIER_QUESTION = "Some Things features have no programmatic surface and are driven through the app's own " +
987
+ "window (editing repeat rules, reordering areas). Enable GUI-driving permissions?";
988
+ /**
989
+ * The full onboarding ceremony behind `things helpers setup`: fire every
990
+ * consent macOS will ever ask for while a human is sitting there, then report
991
+ * where each one landed. Every leg is IDEMPOTENT — an already-granted leg is
992
+ * detected prompt-free (the deputy's own `hello` carries its TCC standing) and
993
+ * skipped — so a rerun on a fully onboarded machine raises nothing and reports
994
+ * all-green. Interactive by design: run it at the machine.
995
+ *
996
+ * Throws when the helpers are not installed or the deputy does not answer;
997
+ * every other outcome is reported per leg. See docs/design/helpers-onboarding.md.
998
+ *
999
+ * Runs under `withDefaultInterrupts` for its whole synchronous span, so Ctrl-C
1000
+ * stops it at a gate AND inside the Accessibility wait (../wizard.ts, "Why a
1001
+ * ceremony runs with the DEFAULT signal disposition"). Throws `CeremonyStopped`
1002
+ * when the human stops at a gate.
1003
+ */
1004
+ export function onboardHelpers(options, env = process.env, deps = {}) {
1005
+ return withDefaultInterrupts(() => runOnboardCeremony(options, env, deps));
1006
+ }
1007
+ function runOnboardCeremony(options, env, deps) {
1008
+ const mode = options.mode;
1009
+ const progress = deps.progress ??
1010
+ ((line) => {
1011
+ process.stdout.write(`${line}\n`);
1012
+ });
1013
+ const wizard = deps.wizard ?? createWizard();
1014
+ if (deps.channel === undefined && !existsSync(deputyInstalledBinaryPath(env))) {
1015
+ throw new Error("the helpers are not installed — run `things helpers setup` first");
1016
+ }
1017
+ // Which tier, decided BEFORE anything is raised so the banner can size the
1018
+ // sitting honestly. The flag wins; `ui-enabled` already on implies the tier
1019
+ // without it (a machine that opted into GUI-driving is asking for the grants
1020
+ // by definition); otherwise a TTY sitting is asked and a strict run is not.
1021
+ const uiEnabled = (deps.uiEnabled ?? (() => loadConfig(env).ui.enabled))();
1022
+ let guiRequestedBy = null;
1023
+ if (options.gui === true) {
1024
+ guiRequestedBy = "flag";
1025
+ }
1026
+ else if (uiEnabled) {
1027
+ guiRequestedBy = "config";
1028
+ progress("ui.enabled is on — including GUI-driving permissions.");
1029
+ }
1030
+ else if (wizard.ask(GUI_TIER_QUESTION, false)) {
1031
+ guiRequestedBy = "wizard";
1032
+ }
1033
+ const tier = guiRequestedBy === null ? "base" : "gui";
1034
+ const channel = deps.channel ?? openDeputyChannel(env, deps.deputyWaitMs ?? DEPUTY_SOCKET_WAIT_MS);
1035
+ const steps = [];
1036
+ let outstanding = [];
1037
+ try {
1038
+ let hello;
1039
+ try {
1040
+ hello = channel.hello();
1041
+ }
1042
+ catch (err) {
1043
+ throw new Error(`the deputy is installed but did not answer (${err instanceof Error ? err.message : String(err)}) — \`things helpers restart\`, then rerun`, { cause: err });
1044
+ }
1045
+ if (hello.deputyVersion !== EXPECTED_HELPERS_VERSION) {
1046
+ progress(`note: the installed helpers are v${hello.deputyVersion}, this package expects v${EXPECTED_HELPERS_VERSION} — rebuild with \`bash scripts/build-helpers.sh\` and rerun \`things helpers setup\` for the full ceremony`);
1047
+ }
1048
+ // A dormant target is settled BEFORE the count, never counted as a dialog
1049
+ // (#610 for System Events, #617 for Things). macOS reaps the agent when it
1050
+ // is idle and the user closes their own app, and the ask-false
1051
+ // determination has no answer for a target that is down, so `not-running`
1052
+ // says nothing about the grant — starting it (a background launch, not an
1053
+ // Apple event) is what makes the standing readable, and only then can the
1054
+ // banner promise the right number of dialogs. For Things the pre-launch
1055
+ // also keeps the leg's own Apple Event from yanking the app frontmost
1056
+ // (A40/A41).
1057
+ let automationThings = hello.automation?.things;
1058
+ if (automationThings === "not-running") {
1059
+ progress("automation → Things: the app was closed — starting it to read its standing");
1060
+ const wake = (deps.wakeThings ?? ((probe) => wakeThings(env, { probe })))(() => {
1061
+ try {
1062
+ return channel.hello().automation?.things;
1063
+ }
1064
+ catch {
1065
+ return undefined;
1066
+ }
1067
+ });
1068
+ automationThings = wake.standing;
1069
+ }
1070
+ let systemEvents = hello.automation?.systemEvents;
1071
+ if (tier === "gui" && systemEvents === "not-running") {
1072
+ progress("automation → System Events: the target was asleep — starting it to read its standing");
1073
+ const wake = (deps.wakeSystemEvents ?? ((probe) => wakeSystemEvents(env, { probe })))(() => {
1074
+ try {
1075
+ return channel.hello().automation?.systemEvents;
1076
+ }
1077
+ catch {
1078
+ return undefined;
1079
+ }
1080
+ });
1081
+ systemEvents = wake.standing;
1082
+ }
1083
+ // Size the sitting BEFORE raising anything, so whoever started this knows
1084
+ // whether they have to stay at the screen.
1085
+ const reader = readerStanding(env, deps.readerProbe ?? (() => readerProbeDefault(env)));
1086
+ outstanding = outstandingPrompts(hello, reader, tier, automationThings, systemEvents);
1087
+ progress(outstanding.length === 0
1088
+ ? "nothing to raise — every permission the helpers need is already on record"
1089
+ : `about to raise ${outstanding.length} macOS consent dialog${outstanding.length === 1 ? "" : "s"} — ` +
1090
+ `${outstanding.map((leg) => PROMPT_LABELS[leg]).join(", ")}. Someone must be at the screen.`);
1091
+ const willRaise = new Set(outstanding);
1092
+ /** Explain a leg's dialog and let the human pace it — wizard mode only. */
1093
+ const brief = (leg) => {
1094
+ if (willRaise.has(leg))
1095
+ wizard.explain(PROMPT_EXPLAINERS[leg]);
1096
+ };
1097
+ brief("reader-read-grant");
1098
+ steps.push(readerLeg(reader, {
1099
+ progress,
1100
+ grant: deps.grant ?? (() => grantReader(env)),
1101
+ }));
1102
+ const automationTimeoutMs = deps.automationTimeoutMs ?? AUTOMATION_PROMPT_TIMEOUT_MS;
1103
+ // Fresh AEDeterminePermission read for one target, off a new hello.
1104
+ const refreshAutomation = (key) => () => {
1105
+ try {
1106
+ return channel.hello().automation?.[key];
1107
+ }
1108
+ catch {
1109
+ return undefined;
1110
+ }
1111
+ };
1112
+ brief("automation-things");
1113
+ steps.push(automationLeg(channel, {
1114
+ leg: "automation-things",
1115
+ label: "automation → Things",
1116
+ // `count of areas` dispatches a REAL Apple event; `version` and its
1117
+ // kin are answered locally and would grant nothing (see automationLeg).
1118
+ script: 'tell application "Things3" to count of areas',
1119
+ settingsName: "Things3",
1120
+ }, automationThings, refreshAutomation("things"), automationTimeoutMs, progress));
1121
+ // The GUI tier. Skipped entirely under the base tier — Accessibility and
1122
+ // System Events are the two widest grants the pair ever holds, so they are
1123
+ // gathered only when GUI-driving was actually asked for (Article V).
1124
+ if (tier === "gui") {
1125
+ brief("automation-system-events");
1126
+ steps.push(automationLeg(channel, {
1127
+ leg: "automation-system-events",
1128
+ label: "automation → System Events",
1129
+ script: 'tell application "System Events" to name of first process',
1130
+ settingsName: "System Events",
1131
+ }, systemEvents, refreshAutomation("systemEvents"), automationTimeoutMs, progress));
1132
+ brief("accessibility");
1133
+ steps.push(accessibilityLeg(channel, hello.axTrusted, {
1134
+ progress,
1135
+ openUrl: deps.openUrl ?? openUrlBestEffort,
1136
+ sleep: deps.sleep ?? syncSleepMs,
1137
+ now: deps.now ?? Date.now,
1138
+ timeoutMs: deps.axTimeoutMs ?? AX_WAIT_TIMEOUT_MS,
1139
+ intervalMs: deps.axIntervalMs ?? AX_POLL_INTERVAL_MS,
1140
+ }));
1141
+ }
1142
+ steps.push(shortcutsLeg(channel, progress));
1143
+ }
1144
+ finally {
1145
+ channel.close();
1146
+ }
1147
+ // A GUI tier that actually landed turns the config key on, so the capability
1148
+ // the user just granted is the capability the engine will use. Only on full
1149
+ // success: switching the key on over a half-granted tier would produce
1150
+ // exactly the "enabled but refuses" state Article IV exists to prevent.
1151
+ const guiGranted = tier === "gui" && steps.every((s) => s.tier !== "gui" || s.state === "granted");
1152
+ let uiEnabledSet = false;
1153
+ if (guiGranted && !uiEnabled) {
1154
+ (deps.setUiEnabled ?? ((value) => saveConfigKey("uiEnabled", value, env)))(true);
1155
+ uiEnabledSet = true;
1156
+ progress("GUI-driving turned on in config (`ui-enabled` is now true).");
1157
+ }
1158
+ return {
1159
+ tier,
1160
+ guiRequestedBy,
1161
+ uiEnabledSet,
1162
+ steps,
1163
+ outstanding,
1164
+ denied: steps.some((s) => s.state === "denied"),
1165
+ pending: steps.some((s) => s.state === "pending"),
1166
+ closing: closingLine(steps, mode, tier),
1167
+ };
1168
+ }
1169
+ function openUrlBestEffort(url) {
1170
+ try {
1171
+ execFileSync("open", [url], { stdio: "ignore", timeout: 10_000 });
1172
+ }
1173
+ catch {
1174
+ // The deep link is a convenience; the written path works without it.
1175
+ }
1176
+ }
370
1177
  /**
371
1178
  * Prompt-free status for both halves: launchd load state, a live handshake
372
1179
  * when each socket answers, and the installed bundle's signing facts. Works
@@ -406,7 +1213,7 @@ export function helpersStatus(mode, env = process.env) {
406
1213
  bundleInstalled: binaryInstalled,
407
1214
  installedVersion: installedHelpersVersion(env),
408
1215
  deputy: {
409
- plistInstalled: existsSync(deputyPlistPath()),
1216
+ plistInstalled: existsSync(deputyPlistPath(env)),
410
1217
  loaded: launchctl(["print", launchTarget()]).ok,
411
1218
  running: hello !== null,
412
1219
  socketPath,
@@ -427,10 +1234,15 @@ function readerStatus(env) {
427
1234
  let running = false;
428
1235
  let granted = false;
429
1236
  let hello = null;
430
- let detail = "not running (no socket)";
431
- const socketPresent = existsSync(socketPath) && existsSync(tokenPath);
432
- if (socketPresent) {
433
- const token = readFileSync(tokenPath, "utf8").trim();
1237
+ // The rendezvous is `<state>/reader`, ours and outside every sandbox
1238
+ // container, so `helpers status` answers this identically from any host app.
1239
+ let detail = "not registered with launchd (no rendezvous)";
1240
+ const socketPresent = rendezvousExists(socketPath) && rendezvousExists(tokenPath);
1241
+ const token = socketPresent ? readRendezvousToken(tokenPath) : null;
1242
+ if (socketPresent && token === null) {
1243
+ detail = `the reader's access token could not be read (${tokenPath}) — \`things helpers setup\` mints a fresh one`;
1244
+ }
1245
+ if (token !== null) {
434
1246
  const bridge = new DeputySyncBridge(socketPath);
435
1247
  try {
436
1248
  const res = bridge.request({ v: DEPUTY_PROTOCOL_VERSION, token, verb: "hello" }, 2000);
@@ -438,7 +1250,7 @@ function readerStatus(env) {
438
1250
  running = true;
439
1251
  hello = res;
440
1252
  granted = res.granted === true;
441
- detail = granted ? "running, granted" : "running, NOT granted (things helpers grant)";
1253
+ detail = granted ? "running, granted" : "running, NOT granted (things helpers setup)";
442
1254
  }
443
1255
  else {
444
1256
  detail = `handshake refused: ${JSON.stringify(res["error"])}`;
@@ -453,12 +1265,12 @@ function readerStatus(env) {
453
1265
  }
454
1266
  return {
455
1267
  installed,
456
- plistInstalled: existsSync(readerPlistPath()),
1268
+ plistInstalled: existsSync(readerPlistPath(env)),
457
1269
  loaded,
458
1270
  running,
459
1271
  granted,
460
1272
  socketPath,
461
- hungSocket: socketPresent && !running,
1273
+ hungSocket: socketPresent && token !== null && !running,
462
1274
  hello,
463
1275
  signing: installed ? deputySigningInfo(readerExecPath(appPath)) : null,
464
1276
  detail,