things-api 0.18.0 → 0.19.0

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