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
@@ -0,0 +1,658 @@
1
+ /**
2
+ * `things setup` — the DIRECT-path onboarding ceremony
3
+ * (docs/design/permissions-doctrine.md, Article V).
4
+ *
5
+ * One of exactly two places in the package allowed to put a macOS consent
6
+ * dialog on screen (the other is `things helpers setup`). Everywhere else
7
+ * detects capability prompt-free and refuses; here we deliberately raise what
8
+ * has to be raised, while a human is sitting at the machine, and report where
9
+ * each grant landed.
10
+ *
11
+ * The legs, in order:
12
+ *
13
+ * a. READ ACCESS. An explicit two-way choice, because the two answers differ in
14
+ * kind rather than in quality. The DEFAULT — a bare Enter — is the session
15
+ * grant: one deliberate container open, which raises the "would like to
16
+ * access data from other apps" modal, and if the open then succeeds the
17
+ * grant is witnessed (./session-grant.ts). It lasts only while the host app
18
+ * stays open, and the copy says so. Typing `f` takes Full Disk Access
19
+ * instead: durable, but it hands the whole disk to a general-purpose host
20
+ * app, so it is offered, never assumed.
21
+ *
22
+ * The provoking open runs in a BOUNDED CHILD process, because macOS parks
23
+ * the requesting syscall in the kernel until someone answers the dialog —
24
+ * an in-process open would park the ceremony itself for as long as the
25
+ * dialog stands. MEASURED (APDP1, docs/lab/apdp1-grant-pinning.md): the
26
+ * app-data grant is keyed to the RESPONSIBLE APP INSTANCE, not to the pid
27
+ * that opened the file, so what a child provokes belongs to the host app
28
+ * and outlives that child — every later process under the same host app
29
+ * reads without a dialog. Killing the child at the deadline takes neither
30
+ * the dialog nor the grant away: a human who answers Allow afterwards still
31
+ * grants the host app, and the next run witnesses it.
32
+ *
33
+ * MEASURED: FDA does NOT take effect for a running app. macOS says so in
34
+ * the Settings sheet itself — "…will not have full disk access until it is
35
+ * quit", with Later / Quit & Reopen — and the responsible process keeps its
36
+ * old answer for its whole life, so every child it spawns, including a
37
+ * rerun of `things` in the same window, still sees no FDA. There is
38
+ * therefore nothing for this leg to wait for: it deep-links Settings, says
39
+ * what to flip and that the app must relaunch, leaves the leg PENDING, and
40
+ * lets the remaining legs run — Automation and the shortcuts land fine in
41
+ * this session, so the rerun after the relaunch has only this leg left.
42
+ * b. APP CONTROL. When macOS has no Automation record for the host app, the
43
+ * only way to mint one is to send a real Apple Event, which is what raises
44
+ * the dialog. Inside a ceremony that is exactly right. A recorded refusal
45
+ * is NOT re-asked (macOS will not show it again); the copy names both the
46
+ * Settings toggle and the `tccutil` re-arm and leaves the choice to the human.
47
+ * c. SHORTCUTS. The bundled proxies that carry the operations no other surface
48
+ * can perform. An install sheet per missing shortcut; no macOS consent.
49
+ * d. THINGS URLS. The odd one out: not a macOS grant at all, but Things' own
50
+ * Settings ▸ General ▸ "Enable Things URLs". It raises nothing and cannot be
51
+ * flipped from here, so this leg only DETECTS and instructs — and it earns
52
+ * its place because the off state is otherwise invisible: the app parks
53
+ * every URL command in an alert on its own window rather than running it
54
+ * (URLEN1, #611), which a fresh install hits on its very first write.
55
+ *
56
+ * There is deliberately NO Accessibility leg: GUI-driving is helpers-only
57
+ * (Article IV), so when `ui.enabled` is set this ceremony says so and points at
58
+ * `things helpers setup --gui` rather than raising an AX prompt against the
59
+ * terminal.
60
+ *
61
+ * Every leg is IDEMPOTENT and RESUMABLE: an already-satisfied leg is detected
62
+ * prompt-free and skipped, so a rerun on a settled machine raises nothing and
63
+ * reports all-green. A leg left outstanding exits nonzero naming what remains.
64
+ *
65
+ * Mode-aware (Article V): off a TTY this is STRICT mode — an upfront banner
66
+ * counting the dialogs, bounded waits, and an unanswered leg failing the run.
67
+ * At a TTY the same legs run as a guided WIZARD: each dialog is explained in
68
+ * the words macOS will use before it is raised, and the ceremony waits for the
69
+ * human between legs (./wizard.ts). TTY-ness is the only signal, and it is read
70
+ * inside this ceremony only.
71
+ */
72
+ import { execFileSync } from "node:child_process";
73
+ import { existsSync } from "node:fs";
74
+ import { join } from "node:path";
75
+ import { fileURLToPath } from "node:url";
76
+ import { hostApp, hostDisplayName, readCapability, urlSchemeCapability, writeCapability, } from "./capability.js";
77
+ import { loadConfig } from "./config.js";
78
+ import { locateThingsDb } from "./db/locate.js";
79
+ import { clearSessionGrant, witnessSessionGrant } from "./session-grant.js";
80
+ import { createWizard, withDefaultInterrupts } from "./wizard.js";
81
+ import { readShortcutProxies } from "./write/availability.js";
82
+ /** Deep link to the Full Disk Access pane. */
83
+ const FDA_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
84
+ /** Deep link to the Automation pane. */
85
+ const AUTOMATION_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation";
86
+ /** Package root — one level above src/ AND dist/, so both layouts resolve. */
87
+ const SHORTCUTS_DIR = fileURLToPath(new URL("../shortcuts", import.meta.url));
88
+ const AUTOMATION_TIMEOUT_MS = 60_000;
89
+ /** How long the read leg waits for the app-data dialog before giving up. */
90
+ const CONTAINER_OPEN_TIMEOUT_MS = 60_000;
91
+ /**
92
+ * The bounded child's whole job: one `open(2)` against the container, then
93
+ * exit. A dynamic import so the same source runs whether node evaluates `-e`
94
+ * as CommonJS or as an ES module; the failure path writes ONLY the errno
95
+ * message, because an unhandled rejection would dump a stack trace whose first
96
+ * line is a node internal rather than the reason.
97
+ */
98
+ const CONTAINER_OPEN_CHILD = 'import("node:fs").then((fs) => fs.closeSync(fs.openSync(process.argv[1], "r")))' +
99
+ ".catch((e) => { process.stderr.write(String((e && e.message) || e)); process.exit(1); });";
100
+ /**
101
+ * The deadline passed with the app-data dialog still unanswered. Distinct from
102
+ * a refusal: nothing was decided, the dialog is still on screen, and answering
103
+ * it later still lands the grant on the host app (APDP1 stage A).
104
+ */
105
+ export class ContainerOpenTimedOut extends Error {
106
+ timeoutMs;
107
+ constructor(timeoutMs) {
108
+ super(`the app-data dialog was not answered within ${Math.round(timeoutMs / 1000)}s`);
109
+ this.name = "ContainerOpenTimedOut";
110
+ this.timeoutMs = timeoutMs;
111
+ }
112
+ }
113
+ /** The key that picks Full Disk Access over the session grant at the read leg. */
114
+ const FDA_CHOICE_KEY = "f";
115
+ function openUrlBestEffort(url) {
116
+ try {
117
+ execFileSync("open", [url], { stdio: "ignore", timeout: 10_000 });
118
+ }
119
+ catch {
120
+ // The deep link is a convenience; the written path works without it.
121
+ }
122
+ }
123
+ /** First line of whatever a failed child wrote to stderr. */
124
+ function firstStderrLine(err) {
125
+ const e = err;
126
+ const text = typeof e.stderr === "string"
127
+ ? e.stderr
128
+ : Buffer.isBuffer(e.stderr)
129
+ ? e.stderr.toString("utf8")
130
+ : err instanceof Error
131
+ ? err.message
132
+ : String(err);
133
+ return text.trim().split("\n")[0] ?? "";
134
+ }
135
+ /**
136
+ * The container open the ceremony performs on purpose. This is the ONE place
137
+ * in the package permitted to do it blind — everywhere else the doctrine's
138
+ * Article I corollary forbids it, because the open is what raises the modal.
139
+ *
140
+ * It runs in a CHILD process so the wait has a deadline (see the read-access
141
+ * note at the top of this file): macOS holds the open in the kernel for as long
142
+ * as the dialog stands, and the grant it lands belongs to the host app rather
143
+ * than to the pid that asked, so nothing is lost by giving up on the child.
144
+ */
145
+ function openContainerDefault(timeoutMs) {
146
+ const path = locateThingsDb().path;
147
+ try {
148
+ execFileSync(process.execPath, ["-e", CONTAINER_OPEN_CHILD, path], {
149
+ stdio: ["ignore", "ignore", "pipe"],
150
+ timeout: timeoutMs,
151
+ killSignal: "SIGKILL",
152
+ });
153
+ }
154
+ catch (err) {
155
+ const e = err;
156
+ // spawnSync reports a deadline kill as ETIMEDOUT, as `killed`, or as the
157
+ // kill signal itself, depending on where it noticed — all three mean the
158
+ // dialog outlived the wait.
159
+ if (e.code === "ETIMEDOUT" || e.killed === true || e.signal === "SIGKILL") {
160
+ throw new ContainerOpenTimedOut(timeoutMs);
161
+ }
162
+ throw new Error(firstStderrLine(err) || "the container could not be opened", { cause: err });
163
+ }
164
+ }
165
+ function sendAutomationProbeDefault(timeoutMs) {
166
+ // `count of areas` dispatches a REAL Apple event. Properties like `version`
167
+ // are answered locally from the target's bundle, mint no grant, and still
168
+ // exit 0 — a false positive that has shipped here before (see the helpers'
169
+ // automationLeg). Never weaken this script.
170
+ execFileSync("osascript", ["-e", 'tell application "Things3" to count of areas'], {
171
+ stdio: ["ignore", "ignore", "pipe"],
172
+ timeout: timeoutMs,
173
+ });
174
+ }
175
+ // ── Leg (a): read access ─────────────────────────────────────────────────────
176
+ /**
177
+ * The two ways to read, offered as a choice rather than a ranking. Enter takes
178
+ * the session grant; `f` takes Full Disk Access, which is the wider grant and
179
+ * the one that costs the human a relaunch.
180
+ *
181
+ * The Enter line states what the session grant actually buys, as MEASURED
182
+ * (APDP1): one Allow covers every process under this host app — this window,
183
+ * other tabs and windows, and anything they spawn — until the app quits. Under
184
+ * tmux that app is the one that first started the tmux server rather than
185
+ * whatever window you are attached from, because macOS fixes responsibility at
186
+ * spawn and it survives re-parenting; the caveat is printed only when `TMUX`
187
+ * says so, and nothing is detected to produce it.
188
+ */
189
+ function readAccessChoice(hostName, env) {
190
+ const lines = [
191
+ "Next: read access to your Things data — two ways:",
192
+ ` Enter allow while ${hostName} runs: one dialog now, then every command under`,
193
+ ` ${hostName} — any tab, window, or agent it spawns — reads without asking,`,
194
+ ` until ${hostName} quits`,
195
+ ` f Full Disk Access: durable, but grants ${hostName} broad file access —`,
196
+ ` flip it in System Settings, then ${hostName} must quit and reopen`,
197
+ ];
198
+ if ((env["TMUX"] ?? "") !== "") {
199
+ lines.push(` note: inside tmux the grant belongs to the app that started the tmux server, and`, ` lasts until THAT app quits — not the window you are attached from`);
200
+ }
201
+ return lines;
202
+ }
203
+ /**
204
+ * The `f` branch. Nothing here waits: FDA reaches a process only through a
205
+ * relaunch, so the leg hands over the three steps and goes pending. The
206
+ * remaining legs still run — their grants land in THIS session — so the rerun
207
+ * after the relaunch has only this one left.
208
+ */
209
+ function fdaBranch(base, hostName, deps) {
210
+ const progress = deps.progress ?? (() => { });
211
+ (deps.openUrl ?? openUrlBestEffort)(FDA_SETTINGS_URL);
212
+ progress(`read access: 1. turn on ${hostName} under System Settings ▸ Privacy & Security ▸ Full Disk Access`);
213
+ progress(`read access: 2. click "Quit & Reopen" when macOS offers it, or quit ${hostName} yourself`);
214
+ progress("read access: 3. run `things setup` again in the new window to confirm it");
215
+ progress("read access: the rest of the setup continues now — those grants land in this session");
216
+ return {
217
+ ...base,
218
+ state: "pending",
219
+ alreadySatisfied: false,
220
+ detail: `Full Disk Access takes effect after ${hostName} relaunches — turn it on, quit and ` +
221
+ `reopen ${hostName}, then rerun \`things setup\``,
222
+ };
223
+ }
224
+ /**
225
+ * The default branch: provoke the app-data modal on purpose and record the
226
+ * grant only if the open then actually succeeded. The wait is BOUNDED — the
227
+ * open runs in a child process the ceremony can give up on, and giving up
228
+ * costs nothing, because a dialog answered afterwards still grants the host
229
+ * app (APDP1).
230
+ */
231
+ function sessionGrantBranch(base, hostName, deps) {
232
+ const progress = deps.progress ?? (() => { });
233
+ progress(`read access: asking now — a dialog asks whether ${hostName} may access data from ` +
234
+ "other apps; click Allow");
235
+ const host = hostApp(deps);
236
+ try {
237
+ (deps.openContainer ?? openContainerDefault)(deps.containerOpenTimeoutMs ?? CONTAINER_OPEN_TIMEOUT_MS);
238
+ }
239
+ catch (err) {
240
+ clearSessionGrant(deps.env ?? process.env);
241
+ if (err instanceof ContainerOpenTimedOut) {
242
+ // Nothing was decided and the dialog is still up: say so, because the
243
+ // human's next click still lands the grant on this same host app.
244
+ progress("read access: no answer yet — the dialog is still on screen");
245
+ return {
246
+ ...base,
247
+ state: "pending",
248
+ alreadySatisfied: false,
249
+ detail: `the dialog is still waiting — click Allow and rerun \`things setup\` to confirm it, ` +
250
+ "choose Full Disk Access at the read step instead, or run `things helpers setup` to " +
251
+ "let a helper hold the grant",
252
+ };
253
+ }
254
+ const why = err instanceof Error ? err.message : String(err);
255
+ progress(`read access: still no access — ${why}`);
256
+ return {
257
+ ...base,
258
+ state: "pending",
259
+ alreadySatisfied: false,
260
+ // The open failed. Usually that is a Don't Allow — which then stands for
261
+ // the whole run of this app, every later open failing instantly with no
262
+ // second dialog (APDP1 stage B) — but the errno could also be something
263
+ // else entirely, so the copy hedges the cause and states the remedy.
264
+ detail: `no read access yet — if the dialog was refused, that answer stands for the rest of ` +
265
+ `this ${hostName} run and macOS will not ask again, so quit and reopen ${hostName} to ` +
266
+ "be asked; or choose Full Disk Access at the read step, or run `things helpers setup` " +
267
+ "to let a helper hold the grant",
268
+ };
269
+ }
270
+ const witnessed = witnessSessionGrant(host.bundleId ?? "", deps);
271
+ if (witnessed === null) {
272
+ return {
273
+ ...base,
274
+ state: "pending",
275
+ alreadySatisfied: false,
276
+ detail: "the folder opened, but this process has no host application whose lifetime the " +
277
+ "grant could be tied to — choose Full Disk Access instead, or run `things helpers setup`",
278
+ };
279
+ }
280
+ progress(`read access: granted — every command under ${hostName}, in any tab or window, reads ` +
281
+ `without asking until ${hostName} quits`);
282
+ return {
283
+ ...base,
284
+ state: "granted",
285
+ alreadySatisfied: false,
286
+ detail: `every command running under ${hostName} — any tab, window, or agent it spawns — may ` +
287
+ `read the Things data folder until ${hostName} quits. Full Disk Access makes it ` +
288
+ "permanent; `things helpers setup` moves it onto a helper that keeps it across restarts",
289
+ };
290
+ }
291
+ function readAccessLeg(capability, choice, deps) {
292
+ const base = { leg: "read-access", label: "read access" };
293
+ const progress = deps.progress ?? (() => { });
294
+ const hostName = hostDisplayName(deps);
295
+ if (capability.mode === "helpers") {
296
+ progress("read access: already served by the helpers");
297
+ return {
298
+ ...base,
299
+ state: "granted",
300
+ alreadySatisfied: true,
301
+ detail: "the helpers' reader holds the read grant",
302
+ };
303
+ }
304
+ if (capability.mode === "direct-fda") {
305
+ progress("read access: already granted (Full Disk Access)");
306
+ return { ...base, state: "granted", alreadySatisfied: true, detail: capability.detail };
307
+ }
308
+ if (capability.mode === "session-grant") {
309
+ progress(`read access: already granted — every command under ${hostName} reads without asking ` +
310
+ `until ${hostName} quits`);
311
+ return { ...base, state: "granted", alreadySatisfied: true, detail: capability.detail };
312
+ }
313
+ // Nothing on record: the human's choice decides which grant this leg gathers.
314
+ return choice === FDA_CHOICE_KEY
315
+ ? fdaBranch(base, hostName, deps)
316
+ : sessionGrantBranch(base, hostName, deps);
317
+ }
318
+ // ── Leg (b): app control ─────────────────────────────────────────────────────
319
+ function appControlLeg(deps) {
320
+ const base = { leg: "app-control", label: "app control" };
321
+ const progress = deps.progress ?? (() => { });
322
+ // DISPATCH intent: this leg sends Things a real Apple Event a few lines down,
323
+ // and an Apple Event to a closed Things launches it WITH focus steal
324
+ // (A40/A41). Starting it first is both the honest way to read the standing
325
+ // (#617) and the quieter way to raise the dialog.
326
+ const capability = writeCapability({ purpose: "dispatch" }, deps);
327
+ const hostName = hostDisplayName(deps);
328
+ if (capability.mode === "deputy") {
329
+ progress("app control: already held by the helpers");
330
+ return {
331
+ ...base,
332
+ state: "granted",
333
+ alreadySatisfied: true,
334
+ detail: "the deputy holds app control for Things",
335
+ };
336
+ }
337
+ if (capability.mode === "direct-granted") {
338
+ progress("app control: already granted");
339
+ return { ...base, state: "granted", alreadySatisfied: true, detail: capability.detail };
340
+ }
341
+ if (capability.mode === "direct-denied") {
342
+ // macOS will not show this dialog again — re-asking is not an option, so
343
+ // the leg reports the two things a human can actually do.
344
+ progress("app control: refused earlier — macOS will not ask again");
345
+ return {
346
+ ...base,
347
+ state: "denied",
348
+ alreadySatisfied: false,
349
+ detail: capability.remediation.join("; "),
350
+ };
351
+ }
352
+ progress("app control: asking now — answer the dialog if one appears");
353
+ try {
354
+ (deps.sendAutomationProbe ?? sendAutomationProbeDefault)(deps.automationTimeoutMs ?? AUTOMATION_TIMEOUT_MS);
355
+ }
356
+ catch (err) {
357
+ const e = err;
358
+ const stderr = typeof e.stderr === "string"
359
+ ? e.stderr
360
+ : Buffer.isBuffer(e.stderr)
361
+ ? e.stderr.toString("utf8")
362
+ : err instanceof Error
363
+ ? err.message
364
+ : String(err);
365
+ if (/-1743/.test(stderr)) {
366
+ progress("app control: refused");
367
+ (deps.openUrl ?? openUrlBestEffort)(AUTOMATION_SETTINGS_URL);
368
+ return {
369
+ ...base,
370
+ state: "denied",
371
+ alreadySatisfied: false,
372
+ detail: `turn on Things3 for ${hostName} under System Settings ▸ Privacy & Security ▸ ` +
373
+ `Automation, or re-arm the request with \`tccutil reset AppleEvents ${hostApp(deps).bundleId ?? "<host app>"}\`, then rerun`,
374
+ };
375
+ }
376
+ if (e.killed === true || /-1712|event timed out/i.test(stderr)) {
377
+ progress("app control: still waiting on the dialog");
378
+ return {
379
+ ...base,
380
+ state: "pending",
381
+ alreadySatisfied: false,
382
+ detail: "the dialog was not answered — answer it and rerun",
383
+ };
384
+ }
385
+ progress(`app control: no grant yet (${stderr.trim().split("\n")[0] ?? "unknown error"})`);
386
+ return {
387
+ ...base,
388
+ state: "pending",
389
+ alreadySatisfied: false,
390
+ detail: stderr.trim().split("\n")[0] ?? "the request did not complete",
391
+ };
392
+ }
393
+ // A zero exit is not believed on its own: re-read what macOS actually records.
394
+ const after = writeCapability({ purpose: "dispatch" }, deps);
395
+ if (after.mode === "direct-granted" || after.mode === "deputy") {
396
+ progress("app control: granted");
397
+ return { ...base, state: "granted", alreadySatisfied: false, detail: "granted" };
398
+ }
399
+ progress("app control: the request ran but macOS records no grant yet");
400
+ return {
401
+ ...base,
402
+ state: "pending",
403
+ alreadySatisfied: false,
404
+ detail: `${after.detail} — rerun, or turn Things3 on for ${hostName} under System Settings ▸ Privacy & Security ▸ Automation`,
405
+ };
406
+ }
407
+ // ── Leg (c): shortcuts ───────────────────────────────────────────────────────
408
+ function shortcutsLeg(deps) {
409
+ const base = { leg: "shortcuts", label: "shortcuts" };
410
+ const progress = deps.progress ?? (() => { });
411
+ const state = (deps.shortcutProxies ?? readShortcutProxies)();
412
+ if (state.missing.length === 0) {
413
+ progress("shortcuts: all installed");
414
+ return {
415
+ ...base,
416
+ state: "granted",
417
+ alreadySatisfied: true,
418
+ detail: `${state.present.length} installed`,
419
+ };
420
+ }
421
+ const opened = [];
422
+ const failures = [];
423
+ for (const name of state.missing) {
424
+ const file = join(SHORTCUTS_DIR, `${name}.shortcut`);
425
+ if (!existsSync(file)) {
426
+ failures.push(`${name} (file missing from the package)`);
427
+ continue;
428
+ }
429
+ try {
430
+ (deps.openShortcut ?? ((f) => execFileSync("open", [f], { timeout: 10_000 })))(file);
431
+ opened.push(name);
432
+ }
433
+ catch {
434
+ failures.push(name);
435
+ }
436
+ }
437
+ if (opened.length > 0) {
438
+ progress(`shortcuts: opened ${opened.length} install sheet${opened.length === 1 ? "" : "s"} — ` +
439
+ 'click "Add Shortcut" on each, then "Always Allow" the first time each one runs');
440
+ }
441
+ if (failures.length > 0)
442
+ progress(`shortcuts: could not open ${failures.join(", ")}`);
443
+ return {
444
+ ...base,
445
+ state: "pending",
446
+ alreadySatisfied: false,
447
+ detail: opened.length > 0
448
+ ? `${opened.length} install sheet${opened.length === 1 ? "" : "s"} opened — click “Add Shortcut” on each, then rerun`
449
+ : `could not open: ${failures.join(", ")}`,
450
+ };
451
+ }
452
+ // ── Leg (d): Things' own URL authorization ───────────────────────────────────
453
+ /**
454
+ * The one leg that raises NOTHING. Things' "Enable Things URLs" is the app's own
455
+ * switch, not a macOS grant: there is no dialog for this ceremony to provoke and
456
+ * nothing it can flip on the human's behalf. What it can do is SAY SO — because
457
+ * the state is silent from every other angle. MEASURED (URLEN1): with the
458
+ * setting off, or never answered, the app puts a "Things URL Scheme" alert on
459
+ * its own window and PARKS the command behind it, so an unattended write reads
460
+ * as a silent no-op minutes later (#611). A fresh install has never answered it,
461
+ * which is precisely when someone is running this ceremony.
462
+ *
463
+ * So the leg reports `pending` and names the toggle; the closing line then
464
+ * carries it as outstanding work and a rerun re-checks it. Settings panes are
465
+ * not deep-linkable the way System Settings panes are — `things:///preferences`
466
+ * is not a route (RESID1) — so the copy names the path instead of opening it.
467
+ */
468
+ function urlSchemeLeg(deps) {
469
+ const base = { leg: "url-scheme", label: "Things URLs" };
470
+ const progress = deps.progress ?? (() => { });
471
+ const standing = (deps.urlSchemeStanding ?? (() => urlSchemeCapability(deps)))();
472
+ if (standing.mode === "enabled") {
473
+ progress("things urls: on");
474
+ return { ...base, state: "granted", alreadySatisfied: true, detail: standing.detail };
475
+ }
476
+ if (standing.mode === "unreadable") {
477
+ // Nothing to report and nothing to ask for: this machine cannot see the
478
+ // setting, so claiming it is unset would be a guess.
479
+ return { ...base, state: "skipped", alreadySatisfied: false, detail: standing.detail };
480
+ }
481
+ progress("things urls: OFF — turn on Things ▸ Settings ▸ General ▸ Enable Things URLs, then rerun. " +
482
+ "Until it is on, Things holds URL commands in an alert on its own window instead of " +
483
+ "running them");
484
+ return { ...base, state: "pending", alreadySatisfied: false, detail: standing.detail };
485
+ }
486
+ // ── The ceremony ─────────────────────────────────────────────────────────────
487
+ /**
488
+ * What each leg puts on screen, for the upfront banner. Named flatly, in the
489
+ * order the ceremony raises them — one clause each, no parenthetical asides.
490
+ */
491
+ function promptLabel(leg, hostName) {
492
+ switch (leg) {
493
+ case "read-access":
494
+ return `data access for ${hostName}`;
495
+ case "app-control":
496
+ return "app control of Things";
497
+ case "shortcuts":
498
+ return "one install sheet per missing shortcut";
499
+ case "url-scheme":
500
+ // Unreachable in practice: this leg raises nothing, so it is never put in
501
+ // `outstanding` — the list this function labels. The case exists because
502
+ // the union must be covered, not because anything prints it.
503
+ return "nothing — the Things URLs setting is flipped by hand";
504
+ }
505
+ }
506
+ /** "a", "a and b", "a, b, and c" — the banner reads as a sentence. */
507
+ function listPhrase(items) {
508
+ if (items.length <= 1)
509
+ return items[0] ?? "";
510
+ if (items.length === 2)
511
+ return `${items[0]} and ${items[1]}`;
512
+ return `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
513
+ }
514
+ /**
515
+ * What the human is about to see, in the words macOS will actually use — the
516
+ * wizard prints these one leg ahead of the dialog (Article V, mode-aware). In
517
+ * strict mode they are never printed; the upfront banner's count stands alone.
518
+ * `{host}` is filled with the detected host app's display name. The read leg is
519
+ * absent because it is a CHOICE rather than an announcement — see
520
+ * {@link readAccessChoice}; the url-scheme leg is absent because it raises
521
+ * nothing there is anything to explain in advance of.
522
+ */
523
+ const PROMPT_EXPLAINERS = {
524
+ "app-control": [
525
+ "Next: permission to control the Things app.",
526
+ ' A macOS dialog will appear: "{host}" wants access to control "Things" — click Allow.',
527
+ " Things opens if it was closed; that is expected.",
528
+ ],
529
+ shortcuts: [
530
+ "Next: the bundled shortcuts.",
531
+ ' One Shortcuts install sheet opens per missing shortcut — click "Add Shortcut" on each.',
532
+ ' The first time each one runs, Shortcuts asks once more; choose "Always Allow".',
533
+ ],
534
+ };
535
+ function closingLine(steps, uiEnabled) {
536
+ const denied = steps.filter((s) => s.state === "denied");
537
+ const pending = steps.filter((s) => s.state === "pending");
538
+ const guiHint = uiEnabled
539
+ ? " Some features drive the Things window; that needs the helpers — `things helpers setup --gui`."
540
+ : "";
541
+ if (denied.length > 0) {
542
+ return (`${denied.map((s) => s.label).join(", ")} ${denied.length === 1 ? "was" : "were"} refused — ` +
543
+ `macOS will not ask again, so the remedy above has to be done by hand.${guiHint}`);
544
+ }
545
+ if (pending.length > 0) {
546
+ return `still outstanding: ${pending.map((s) => s.label).join(", ")}. Finish those and rerun \`things setup\` — it resumes exactly here.${guiHint}`;
547
+ }
548
+ return `Everything this machine needs is in place.${guiHint}`;
549
+ }
550
+ /**
551
+ * What the ceremony would do, established entirely prompt-free. This is the
552
+ * idempotence check (Article V): it is what lets a rerun skip settled legs, and
553
+ * it is what `--dry-run` reports. Raises nothing, ever.
554
+ *
555
+ * `purpose` is the one side effect the survey can be asked for: the ceremony
556
+ * itself passes `dispatch` so a dormant Things is started before the dialogs
557
+ * are COUNTED (#617) — otherwise a machine whose app happens to be closed is
558
+ * told "nothing to raise" and then shown a dialog anyway. `--dry-run` keeps the
559
+ * default and starts nothing at all, at the cost of not knowing where a closed
560
+ * app's grant stands; it says so through the write verdict it reports.
561
+ */
562
+ export function surveySetup(deps = {}, options = {}) {
563
+ const host = hostApp(deps);
564
+ const read = readCapability({}, deps);
565
+ const write = writeCapability(options, deps);
566
+ const shortcuts = (deps.shortcutProxies ?? readShortcutProxies)();
567
+ const urlScheme = (deps.urlSchemeStanding ?? (() => urlSchemeCapability(deps)))();
568
+ const outstanding = [];
569
+ if (read.mode !== "direct-fda" && read.mode !== "session-grant" && read.mode !== "helpers") {
570
+ outstanding.push("read-access");
571
+ }
572
+ // A recorded refusal raises nothing (the dialog is spent), so it is not counted.
573
+ if (write.mode === "direct-unknown")
574
+ outstanding.push("app-control");
575
+ if (shortcuts.missing.length > 0)
576
+ outstanding.push("shortcuts");
577
+ // The URL-scheme leg is deliberately NOT added to `outstanding`: that list is
578
+ // "dialogs this ceremony is about to raise", and this leg raises none. It is
579
+ // reported as a step and carried by the closing line instead.
580
+ return {
581
+ host: { bundleId: host.bundleId, name: hostDisplayName(deps) },
582
+ read,
583
+ write,
584
+ urlScheme,
585
+ shortcutsMissing: [...shortcuts.missing],
586
+ outstanding,
587
+ };
588
+ }
589
+ /**
590
+ * Run the direct-path ceremony. Strict mode: the banner counts what is about
591
+ * to appear, waits are bounded, and an unanswered leg leaves the run nonzero.
592
+ *
593
+ * Runs under {@link withDefaultInterrupts} for its whole synchronous span, so
594
+ * a Ctrl-C at a gate — or during a leg's bounded wait — actually stops it
595
+ * (./wizard.ts, "Why a ceremony runs with the DEFAULT signal disposition").
596
+ * Throws {@link CeremonyStopped} when the human stops at a gate.
597
+ */
598
+ export function directSetup(deps = {}) {
599
+ return withDefaultInterrupts(() => runCeremony(deps));
600
+ }
601
+ function runCeremony(deps) {
602
+ const progress = deps.progress ?? ((line) => process.stdout.write(`${line}\n`));
603
+ const withProgress = { ...deps, progress };
604
+ const env = deps.env ?? process.env;
605
+ const host = hostApp(withProgress);
606
+ const hostName = hostDisplayName(withProgress);
607
+ // Survey prompt-free BEFORE raising anything, so whoever started this knows
608
+ // whether they must stay at the screen (Article V).
609
+ const survey = surveySetup(withProgress, { purpose: "dispatch" });
610
+ const readBefore = survey.read;
611
+ const outstanding = survey.outstanding;
612
+ progress(`setting up direct access for ${hostName}${host.bundleId !== null ? ` (${host.bundleId})` : ""}`);
613
+ progress(outstanding.length === 0
614
+ ? "nothing to raise — every permission this machine needs is already on record"
615
+ : `about to raise ${outstanding.length} dialog${outstanding.length === 1 ? "" : "s"} — ` +
616
+ `${listPhrase(outstanding.map((leg) => promptLabel(leg, hostName)))}. ` +
617
+ "Someone must be at the screen.");
618
+ const wizard = deps.wizard ?? createWizard();
619
+ const willRaise = new Set(outstanding);
620
+ /** Explain a leg's dialog and let the human pace it — wizard mode only. */
621
+ const brief = (leg) => {
622
+ if (willRaise.has(leg)) {
623
+ wizard.explain(PROMPT_EXPLAINERS[leg].map((line) => line.replaceAll("{host}", hostName)));
624
+ }
625
+ };
626
+ // The read leg is the one CHOICE in the ceremony: Enter takes the session
627
+ // grant, `f` takes Full Disk Access. Strict mode answers "" without asking,
628
+ // which is the session grant — the only one an absent human can still get.
629
+ const readChoice = willRaise.has("read-access")
630
+ ? wizard.choose(readAccessChoice(hostName, env), [FDA_CHOICE_KEY])
631
+ : "";
632
+ const readStep = readAccessLeg(readBefore, readChoice, withProgress);
633
+ brief("app-control");
634
+ const appControlStep = appControlLeg(withProgress);
635
+ brief("shortcuts");
636
+ const steps = [
637
+ readStep,
638
+ appControlStep,
639
+ shortcutsLeg(withProgress),
640
+ // Last, and never briefed: it raises nothing, so there is no dialog to
641
+ // explain one leg ahead (Article V) — it only reports and instructs.
642
+ urlSchemeLeg(withProgress),
643
+ ];
644
+ const uiEnabled = loadConfig(env).ui.enabled;
645
+ if (uiEnabled) {
646
+ progress("note: GUI-driving is enabled in config, and it is granted only to the helpers — " +
647
+ "run `things helpers setup --gui` to onboard it");
648
+ }
649
+ return {
650
+ host: { bundleId: host.bundleId, name: hostName },
651
+ steps,
652
+ outstanding,
653
+ denied: steps.some((s) => s.state === "denied"),
654
+ pending: steps.some((s) => s.state === "pending"),
655
+ closing: closingLine(steps, uiEnabled),
656
+ };
657
+ }
658
+ //# sourceMappingURL=direct-setup.js.map