@gajae-code/coding-agent 0.16.7 → 0.17.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 (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -0,0 +1,1449 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import type { Process as NativeProcess } from "@gajae-code/natives";
7
+ import { nativeProcessBindings } from "@gajae-code/utils/native-process";
8
+ import * as postmortem from "@gajae-code/utils/postmortem";
9
+ import { sanitizeDisplayLine } from "@gajae-code/utils/sanitize-text";
10
+ import { acquireFileLock } from "../config/file-lock";
11
+
12
+ export const COMMUNITY_APP_REPOSITORY = "devswha/gajae-code-app";
13
+ export const COMMUNITY_APP_BUNDLE_ID = "app.gajae.desktop";
14
+ export const COMMUNITY_APP_TEAM_ID = "5987KT43TJ";
15
+ export const COMMUNITY_APP_SIGNING_AUTHORITY = "Developer ID Application: sangwoo ha";
16
+ const COMMUNITY_APP_SIGNING_REQUIREMENT = [
17
+ "anchor apple generic",
18
+ `identifier "${COMMUNITY_APP_BUNDLE_ID}"`,
19
+ "certificate 1[field.1.2.840.113635.100.6.2.6] exists",
20
+ "certificate leaf[field.1.2.840.113635.100.6.1.13] exists",
21
+ `certificate leaf[subject.OU] = "${COMMUNITY_APP_TEAM_ID}"`,
22
+ `certificate leaf[subject.CN] = "${COMMUNITY_APP_SIGNING_AUTHORITY} (${COMMUNITY_APP_TEAM_ID})"`,
23
+ ].join(" and ");
24
+ export const COMMUNITY_APP_SUPPRESS_ENV = "GJC_NO_COMMUNITY_APP";
25
+ const GITHUB_API_ORIGIN = "https://api.github.com";
26
+ const GITHUB_RELEASE_ORIGIN = "https://github.com";
27
+ const MAX_DMG_BYTES = 512 * 1024 * 1024;
28
+ const MAX_RELEASE_BYTES = 1024 * 1024;
29
+ const MAX_CHECKSUM_BYTES = 128 * 1024;
30
+ const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
31
+ const METADATA_FETCH_TIMEOUT_MS = 30_000;
32
+ const ASSET_FETCH_TIMEOUT_MS = 10 * 60_000;
33
+ const COMMAND_TIMEOUT_MS = 120_000;
34
+ const CLEANUP_COMMAND_TIMEOUT_MS = 15_000;
35
+ const SIGNAL_CLEANUP_COMMAND_TIMEOUT_MS = 1_000;
36
+ const HELPER_REAP_TIMEOUT_MS = 750;
37
+ const HELPER_REAP_POLL_ATTEMPTS = 10;
38
+ const HELPER_OUTPUT_SETTLE_GRACE_MS = 250;
39
+ const MAX_LOG_CHARS = 1024;
40
+ interface CommandResult {
41
+ exitCode: number;
42
+ stdout: string;
43
+ stderr: string;
44
+ timedOut?: boolean;
45
+ // The leader is reaped and descendant ownership is discharged (terminated or
46
+ // transferred to an identity-checked mount lease). helpersRetained distinguishes
47
+ // that transfer from a fully terminated process tree.
48
+ reaped?: boolean;
49
+ helpersRetained?: boolean;
50
+ }
51
+ type CommandRunner = (argv: string[]) => Promise<CommandResult>;
52
+ const activeCommandControllers = new Set<AbortController>();
53
+
54
+ interface ReleaseAsset {
55
+ name: string;
56
+ browser_download_url: string;
57
+ }
58
+
59
+ interface ReleasePayload {
60
+ draft?: boolean;
61
+ prerelease?: boolean;
62
+ assets?: ReleaseAsset[];
63
+ html_url?: string;
64
+ tag_name?: string;
65
+ }
66
+
67
+ export interface CommunityAppOfferDependencies {
68
+ platform?: NodeJS.Platform;
69
+ arch?: string;
70
+ env?: NodeJS.ProcessEnv;
71
+ homeDir?: string;
72
+ tempDir?: string;
73
+ stdinIsTTY?: boolean;
74
+ stdoutIsTTY?: boolean;
75
+ signal?: AbortSignal;
76
+ fetchImpl?: (url: string, init?: RequestInit) => Promise<Response>;
77
+ prompt?: () => Promise<boolean>;
78
+ command?: CommandRunner;
79
+ cleanupCommand?: (argv: string[], timeoutMs: number) => Promise<CommandResult>;
80
+ log?: (message: string) => void;
81
+ }
82
+
83
+ export interface CommunityAppOfferResult {
84
+ status: "skipped" | "installed" | "failed";
85
+ reason: string;
86
+ }
87
+
88
+ function isMacArchitecture(arch: string): arch is "arm64" | "x64" {
89
+ return arch === "arm64" || arch === "x64";
90
+ }
91
+
92
+ function assetMatchesArchitecture(name: string, arch: "arm64" | "x64", version?: string): boolean {
93
+ const versionPattern = version
94
+ ? version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
95
+ : "[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?";
96
+ return new RegExp(`^gajae-app-desktop-${versionPattern}-macos-${arch}\\.dmg$`, "i").test(name);
97
+ }
98
+
99
+ function releaseVersion(tag: string | undefined): string | undefined {
100
+ if (!tag || !/^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/u.test(tag)) return undefined;
101
+ return tag.slice(1);
102
+ }
103
+
104
+ function trustedReleaseAssetUrl(value: string, expectedName: string, expectedTag: string): boolean {
105
+ try {
106
+ const url = new URL(value);
107
+ return (
108
+ url.protocol === "https:" &&
109
+ url.origin === GITHUB_RELEASE_ORIGIN &&
110
+ url.pathname.startsWith(`/${COMMUNITY_APP_REPOSITORY}/releases/download/${expectedTag}/`) &&
111
+ decodeURIComponent(path.posix.basename(url.pathname)) === expectedName
112
+ );
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ function parseChecksum(text: string, assetName: string): string | undefined {
119
+ for (const line of text.split(/\r?\n/)) {
120
+ const match = line.trim().match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/);
121
+ if (!match) continue;
122
+ if (match[2] === assetName || path.basename(match[2]) === assetName) return match[1].toLowerCase();
123
+ }
124
+ return undefined;
125
+ }
126
+
127
+ async function readResponseBytes(response: Response, maxBytes: number): Promise<Uint8Array> {
128
+ const declaredLength = Number(response.headers.get("content-length"));
129
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
130
+ throw new Error(`DMG exceeds the ${maxBytes} byte safety limit`);
131
+ }
132
+ if (!response.body) {
133
+ const bytes = new Uint8Array(await response.arrayBuffer());
134
+ if (bytes.byteLength > maxBytes) throw new Error(`DMG exceeds the ${maxBytes} byte safety limit`);
135
+ return bytes;
136
+ }
137
+ const reader = response.body.getReader();
138
+ const chunks: Uint8Array[] = [];
139
+ let total = 0;
140
+ try {
141
+ for (;;) {
142
+ const { done, value } = await reader.read();
143
+ if (done) break;
144
+ if (value) {
145
+ total += value.byteLength;
146
+ if (total > maxBytes) throw new Error(`DMG exceeds the ${maxBytes} byte safety limit`);
147
+ chunks.push(value);
148
+ }
149
+ }
150
+ } catch (error) {
151
+ await reader.cancel().catch(() => undefined);
152
+ throw error;
153
+ } finally {
154
+ reader.releaseLock();
155
+ }
156
+ const bytes = new Uint8Array(total);
157
+ let offset = 0;
158
+ for (const chunk of chunks) {
159
+ bytes.set(chunk, offset);
160
+ offset += chunk.byteLength;
161
+ }
162
+ return bytes;
163
+ }
164
+
165
+ async function readResponseText(response: Response, maxBytes: number): Promise<string> {
166
+ return new TextDecoder().decode(await readResponseBytes(response, maxBytes));
167
+ }
168
+
169
+ async function writeResponseToFileAndHash(
170
+ response: Response,
171
+ handle: fs.FileHandle,
172
+ maxBytes: number,
173
+ ): Promise<string> {
174
+ const declaredLength = Number(response.headers.get("content-length"));
175
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
176
+ throw new Error(`DMG exceeds the ${maxBytes} byte safety limit`);
177
+ }
178
+ const hash = createHash("sha256");
179
+ let total = 0;
180
+ const writeChunk = async (chunk: Uint8Array): Promise<void> => {
181
+ total += chunk.byteLength;
182
+ if (total > maxBytes) throw new Error(`DMG exceeds the ${maxBytes} byte safety limit`);
183
+ hash.update(chunk);
184
+ let offset = 0;
185
+ while (offset < chunk.byteLength) {
186
+ const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset, null);
187
+ if (bytesWritten === 0) throw new Error("DMG staging write made no progress");
188
+ offset += bytesWritten;
189
+ }
190
+ };
191
+ if (!response.body) {
192
+ await writeChunk(new Uint8Array(await response.arrayBuffer()));
193
+ return hash.digest("hex");
194
+ }
195
+ const reader = response.body.getReader();
196
+ try {
197
+ for (;;) {
198
+ const { done, value } = await reader.read();
199
+ if (done) break;
200
+ if (value) await writeChunk(value);
201
+ }
202
+ } catch (error) {
203
+ await reader.cancel().catch(() => undefined);
204
+ throw error;
205
+ } finally {
206
+ reader.releaseLock();
207
+ }
208
+ return hash.digest("hex");
209
+ }
210
+
211
+ type FileIdentity = { dev: bigint; ino: bigint };
212
+
213
+ async function fileIdentity(filePath: string): Promise<FileIdentity | undefined> {
214
+ const stat = await fs.lstat(filePath, { bigint: true }).catch(() => undefined);
215
+ return stat?.isDirectory() ? { dev: stat.dev, ino: stat.ino } : undefined;
216
+ }
217
+
218
+ async function pathIdentity(filePath: string): Promise<FileIdentity | undefined> {
219
+ const stat = await fs.lstat(filePath, { bigint: true }).catch(() => undefined);
220
+ return stat ? { dev: stat.dev, ino: stat.ino } : undefined;
221
+ }
222
+
223
+ async function sameDirectoryIdentity(filePath: string, expected: FileIdentity): Promise<boolean> {
224
+ const actual = await fileIdentity(filePath);
225
+ return actual?.dev === expected.dev && actual.ino === expected.ino;
226
+ }
227
+
228
+ async function samePathIdentity(filePath: string, expected: FileIdentity): Promise<boolean> {
229
+ const actual = await pathIdentity(filePath);
230
+ return actual?.dev === expected.dev && actual.ino === expected.ino;
231
+ }
232
+
233
+ async function removeClaimedDirectory(
234
+ filePath: string,
235
+ identity: FileIdentity,
236
+ parentPath: string,
237
+ parentIdentity: FileIdentity,
238
+ log: (message: string) => void,
239
+ onRetainedPath?: (retainedPath: string) => void,
240
+ ): Promise<boolean> {
241
+ if (!(await sameDirectoryIdentity(parentPath, parentIdentity))) {
242
+ log("Optional community app cleanup warning: parent identity changed before removal");
243
+ return false;
244
+ }
245
+ if (!(await sameDirectoryIdentity(filePath, identity))) {
246
+ log("Optional community app cleanup warning: claimed directory identity changed before removal");
247
+ return false;
248
+ }
249
+ const quarantineRoot = path.join(parentPath, `.gjc-community-app-cleanup-${process.pid}-${Date.now().toString(16)}`);
250
+ try {
251
+ await fs.mkdir(quarantineRoot, { mode: 0o700 });
252
+ const quarantineIdentity = await fileIdentity(quarantineRoot);
253
+ if (!quarantineIdentity || !(await sameDirectoryIdentity(parentPath, parentIdentity))) {
254
+ log("Optional community app cleanup warning: quarantine identity changed before removal");
255
+ return false;
256
+ }
257
+ const tombstone = path.join(quarantineRoot, path.basename(filePath));
258
+ await fs.rename(filePath, tombstone);
259
+ onRetainedPath?.(tombstone);
260
+ if (
261
+ !(await sameDirectoryIdentity(parentPath, parentIdentity)) ||
262
+ !(await sameDirectoryIdentity(quarantineRoot, quarantineIdentity)) ||
263
+ !(await sameDirectoryIdentity(tombstone, identity))
264
+ ) {
265
+ log("Optional community app cleanup warning: claimed destination identity changed during removal");
266
+ return false;
267
+ }
268
+ await fs.rm(tombstone, { recursive: true, force: true });
269
+ onRetainedPath?.(quarantineRoot);
270
+ if (
271
+ !(await sameDirectoryIdentity(parentPath, parentIdentity)) ||
272
+ !(await sameDirectoryIdentity(quarantineRoot, quarantineIdentity))
273
+ )
274
+ return false;
275
+ await fs.rmdir(quarantineRoot);
276
+ return true;
277
+ } catch (error) {
278
+ log(`Optional community app cleanup warning: failed to remove partial app state: ${String(error)}`);
279
+ return false;
280
+ }
281
+ }
282
+
283
+ // A successful attach owns a resource, not just a command. Its pinned process tree must
284
+ // live until an identity-checked detach restores the original directory. Never infer
285
+ // ownership from a stale PGID or a helper's executable name.
286
+ interface MountedCommandLease {
287
+ before: FileIdentity;
288
+ mounted?: FileIdentity;
289
+ parent: FileIdentity;
290
+ release?: () => Promise<boolean>;
291
+ busy: boolean;
292
+ }
293
+ const mountedCommandLeases = new Map<string, MountedCommandLease>();
294
+
295
+ async function runCommand(
296
+ argv: string[],
297
+ timeoutMs = COMMAND_TIMEOUT_MS,
298
+ onDescendant?: (pid: number) => void,
299
+ ): Promise<CommandResult> {
300
+ const attach = argv[0] === "/usr/bin/hdiutil" && argv[1] === "attach";
301
+ const detach = argv[0] === "/usr/bin/hdiutil" && argv[1] === "detach";
302
+ if (!attach && !detach) return runNativeCommand(argv, timeoutMs, onDescendant);
303
+ const mountArg = attach ? argv[argv.indexOf("-mountpoint") + 1] : argv[2];
304
+ const mount = mountArg && (await fs.realpath(mountArg).catch(() => undefined));
305
+ const unsafe = (reason: string): CommandResult => ({ exitCode: 125, stdout: "", stderr: reason, reaped: false });
306
+ if (!mount || (attach && !argv.includes("-mountpoint"))) return unsafe("mount ownership path unavailable");
307
+ const ownedArgv = [...argv];
308
+ ownedArgv[attach ? argv.indexOf("-mountpoint") + 1 : 2] = mount;
309
+ if (attach) {
310
+ if (mountedCommandLeases.has(mount)) return unsafe("mount already has an owned attach transaction");
311
+ const before = await fileIdentity(mount);
312
+ const parent = await fileIdentity(path.dirname(mount));
313
+ if (!before || !parent) return unsafe("mount ownership identity unavailable");
314
+ const lease: MountedCommandLease = { before, parent, busy: true };
315
+ if (mountedCommandLeases.has(mount)) return unsafe("mount already has an owned attach transaction");
316
+ mountedCommandLeases.set(mount, lease);
317
+ try {
318
+ const result = await runNativeCommand(ownedArgv, timeoutMs, onDescendant, async release => {
319
+ // Transfer ownership before awaiting filesystem evidence, including unknown state.
320
+ lease.release = release;
321
+ const observed = await fileIdentity(mount);
322
+ if (
323
+ observed &&
324
+ (observed.dev !== before.dev || observed.ino !== before.ino) &&
325
+ (await sameDirectoryIdentity(path.dirname(mount), parent))
326
+ )
327
+ lease.mounted = observed;
328
+ return Boolean(lease.mounted);
329
+ });
330
+ if (!lease.release && result.reaped === true) {
331
+ // Failed/interrupted attach may still leave a partial mount after its
332
+ // process tree was conclusively reaped. Preserve its detach authority too.
333
+ const observed = await fileIdentity(mount);
334
+ if (!observed || !(await sameDirectoryIdentity(path.dirname(mount), parent)))
335
+ return unsafe("failed attach left unknown mount state");
336
+ if (observed.dev !== before.dev || observed.ino !== before.ino) {
337
+ lease.mounted = observed;
338
+ lease.release = async () => result.reaped === true;
339
+ }
340
+ }
341
+ return result;
342
+ } finally {
343
+ lease.busy = false;
344
+ if (!lease.release) mountedCommandLeases.delete(mount);
345
+ }
346
+ }
347
+ const lease = mountedCommandLeases.get(mount);
348
+ // Do not use this runner to detach a resource whose ownership cannot be proven.
349
+ if (!lease || lease.busy || !lease.mounted || !lease.release)
350
+ return unsafe("owned mount identity unavailable; refusing detach");
351
+ lease.busy = true;
352
+ try {
353
+ if (
354
+ !(await sameDirectoryIdentity(path.dirname(mount), lease.parent)) ||
355
+ !(await sameDirectoryIdentity(mount, lease.mounted))
356
+ )
357
+ return unsafe("owned mount identity changed; refusing detach");
358
+ const result = await runNativeCommand(ownedArgv, timeoutMs, onDescendant);
359
+ if (result.exitCode !== 0 || result.reaped !== true) return result;
360
+ if (
361
+ !(await sameDirectoryIdentity(path.dirname(mount), lease.parent)) ||
362
+ !(await sameDirectoryIdentity(mount, lease.before))
363
+ )
364
+ return unsafe("detach did not restore the owned directory");
365
+ const reaped = await lease.release();
366
+ if (reaped) mountedCommandLeases.delete(mount);
367
+ return { ...result, reaped };
368
+ } finally {
369
+ lease.busy = false;
370
+ }
371
+ }
372
+
373
+ async function runNativeCommand(
374
+ argv: string[],
375
+ timeoutMs = COMMAND_TIMEOUT_MS,
376
+ onDescendant?: (pid: number) => void,
377
+ retainOnSuccess?: (release: () => Promise<boolean>) => Promise<boolean>,
378
+ ): Promise<CommandResult> {
379
+ const controller = new AbortController();
380
+ const child = Bun.spawn(argv, {
381
+ stdout: "pipe",
382
+ stderr: "pipe",
383
+ signal: controller.signal,
384
+ killSignal: "SIGTERM",
385
+ detached: true,
386
+ });
387
+ activeCommandControllers.add(controller);
388
+ const waitForSpawnGroupGone = async (): Promise<boolean> => {
389
+ for (let attempt = 0; attempt < HELPER_REAP_POLL_ATTEMPTS; attempt++) {
390
+ try {
391
+ process.kill(-child.pid, 0);
392
+ } catch (error) {
393
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") return true;
394
+ }
395
+ await Bun.sleep(50);
396
+ }
397
+ return false;
398
+ };
399
+ const processRef = nativeProcessBindings().Process.fromPid(child.pid);
400
+ if (!processRef) {
401
+ child.kill("SIGKILL");
402
+ await Promise.race([child.exited.catch(() => undefined), Bun.sleep(500)]);
403
+ const reaped = await waitForSpawnGroupGone();
404
+ activeCommandControllers.delete(controller);
405
+ return {
406
+ exitCode: 125,
407
+ stdout: "",
408
+ stderr: `could not bind helper process identity: ${argv[0]}`,
409
+ reaped,
410
+ };
411
+ }
412
+ const descendants = new Map<string, NativeProcess>();
413
+ const captureDescendants = (parent: NativeProcess): void => {
414
+ if (parent.status() !== "running") return;
415
+ for (const descendant of parent.children()) {
416
+ if (parent.status() !== "running") return;
417
+ if (!descendants.has(descendant.incarnation)) {
418
+ descendants.set(descendant.incarnation, descendant);
419
+ onDescendant?.(descendant.pid);
420
+ }
421
+ captureDescendants(descendant);
422
+ }
423
+ };
424
+ const captureOwnedDescendants = (): void => {
425
+ captureDescendants(processRef);
426
+ // Pinned children remain authoritative after reparenting; keep tracking their children too.
427
+ for (const descendant of [...descendants.values()]) captureDescendants(descendant);
428
+ };
429
+ let ownershipTransferred = false;
430
+ let releasingOwnership = false;
431
+ let trackingDescendants = true;
432
+ let descendantTimer: NodeJS.Timeout | undefined;
433
+ let terminationPromise: Promise<boolean> | undefined;
434
+ const descendantTracker = (async () => {
435
+ while (trackingDescendants) {
436
+ captureOwnedDescendants();
437
+ const delay = Promise.withResolvers<void>();
438
+ descendantTimer = setTimeout(delay.resolve, 25);
439
+ if (ownershipTransferred && !releasingOwnership) descendantTimer.unref();
440
+ await delay.promise;
441
+ }
442
+ captureOwnedDescendants();
443
+ })();
444
+ const terminateAndReap = (gracefulMs: number): Promise<boolean> => {
445
+ if (!terminationPromise) {
446
+ terminationPromise = (async () => {
447
+ captureOwnedDescendants();
448
+ const rootExited = await processRef.terminate({
449
+ group: false,
450
+ gracefulMs,
451
+ timeoutMs: HELPER_REAP_TIMEOUT_MS,
452
+ });
453
+ const terminated = new Set<string>();
454
+ let descendantsExited = true;
455
+ const deadline = performance.now() + HELPER_REAP_TIMEOUT_MS;
456
+ for (;;) {
457
+ captureOwnedDescendants();
458
+ const pending = [...descendants.values()].filter(ref => !terminated.has(ref.incarnation));
459
+ if (pending.length === 0) break;
460
+ const remaining = Math.max(0, deadline - performance.now());
461
+ if (remaining === 0) {
462
+ descendantsExited = false;
463
+ break;
464
+ }
465
+ const results = await Promise.all(
466
+ pending.map(ref => {
467
+ terminated.add(ref.incarnation);
468
+ return ref.terminate({ group: false, gracefulMs: -1, timeoutMs: Math.ceil(remaining) });
469
+ }),
470
+ );
471
+ descendantsExited = results.every(Boolean) && descendantsExited;
472
+ }
473
+ return rootExited && descendantsExited && (await waitForSpawnGroupGone());
474
+ })();
475
+ }
476
+ return terminationPromise;
477
+ };
478
+ controller.signal.addEventListener(
479
+ "abort",
480
+ () => {
481
+ if (!ownershipTransferred) void terminateAndReap(500);
482
+ },
483
+ { once: true },
484
+ );
485
+ try {
486
+ const childExit = child.exited.then(
487
+ code => ({ code, reaped: true }),
488
+ () => ({ code: 125, reaped: false }),
489
+ );
490
+ const outputPromise = Promise.all([
491
+ readResponseText(new Response(child.stdout), MAX_COMMAND_OUTPUT_BYTES),
492
+ readResponseText(new Response(child.stderr), MAX_COMMAND_OUTPUT_BYTES),
493
+ childExit,
494
+ ]);
495
+ const timeout = Promise.withResolvers<boolean>();
496
+ const timer: NodeJS.Timeout = setTimeout(() => {
497
+ void terminateAndReap(500).then(reaped => timeout.resolve(reaped));
498
+ }, timeoutMs);
499
+ const output = await Promise.race([
500
+ outputPromise
501
+ .then(value => ({ kind: "output" as const, value }))
502
+ .catch(error => ({ kind: "error" as const, error })),
503
+ timeout.promise.then(reaped => ({ kind: "timeout" as const, reaped })),
504
+ ]);
505
+ clearTimeout(timer);
506
+ if (output.kind === "timeout") {
507
+ await Promise.race([outputPromise.catch(() => undefined), Bun.sleep(HELPER_OUTPUT_SETTLE_GRACE_MS)]);
508
+ return {
509
+ exitCode: 124,
510
+ stdout: "",
511
+ stderr: `command timed out: ${argv[0]}`,
512
+ timedOut: true,
513
+ reaped: output.reaped,
514
+ };
515
+ }
516
+ if (output.kind === "error") {
517
+ const reaped = await terminateAndReap(-1);
518
+ return { exitCode: 125, stdout: "", stderr: String(output.error), reaped };
519
+ }
520
+ const [stdout, stderr, childResult] = output.value;
521
+ if (
522
+ retainOnSuccess &&
523
+ childResult.code === 0 &&
524
+ childResult.reaped &&
525
+ !terminationPromise &&
526
+ !controller.signal.aborted
527
+ ) {
528
+ ownershipTransferred = true;
529
+ const owned = await retainOnSuccess(async () => {
530
+ releasingOwnership = true;
531
+ descendantTimer?.ref();
532
+ let reaped = false;
533
+ try {
534
+ reaped = await terminateAndReap(0);
535
+ return reaped;
536
+ } finally {
537
+ releasingOwnership = false;
538
+ if (reaped) {
539
+ trackingDescendants = false;
540
+ await descendantTracker;
541
+ } else {
542
+ // Unknown survivors remain pinned and tracked; do not silently abandon them.
543
+ descendantTimer?.unref();
544
+ }
545
+ }
546
+ });
547
+ return {
548
+ exitCode: owned ? 0 : 125,
549
+ stdout,
550
+ stderr: owned ? stderr : `${stderr}\nattached mount identity unavailable; helper ownership retained`,
551
+ reaped: owned,
552
+ helpersRetained: true,
553
+ };
554
+ }
555
+ const reaped = childResult.reaped && (await terminateAndReap(0));
556
+ return {
557
+ exitCode: retainOnSuccess && childResult.code === 0 ? 125 : childResult.code,
558
+ stdout,
559
+ stderr,
560
+ reaped,
561
+ };
562
+ } finally {
563
+ if (!ownershipTransferred) {
564
+ trackingDescendants = false;
565
+ await descendantTracker;
566
+ }
567
+ activeCommandControllers.delete(controller);
568
+ }
569
+ }
570
+
571
+ function requireReapedVerifier(result: CommandResult): void {
572
+ if (result.reaped === false) throw new Error("installed app discovery helper did not terminate safely");
573
+ }
574
+
575
+ async function readBundleValue(bundlePath: string, key: string, command: CommandRunner): Promise<string | undefined> {
576
+ const result = await command([
577
+ "/usr/bin/plutil",
578
+ "-extract",
579
+ key,
580
+ "raw",
581
+ "-o",
582
+ "-",
583
+ path.join(bundlePath, "Contents", "Info.plist"),
584
+ ]);
585
+ requireReapedVerifier(result);
586
+ return result.exitCode === 0 ? result.stdout.trim() || undefined : undefined;
587
+ }
588
+
589
+ async function isExpectedBundle(bundlePath: string, command: CommandRunner): Promise<boolean> {
590
+ if (!(await validateBundleLayout(bundlePath))) return false;
591
+ return (await readBundleValue(bundlePath, "CFBundleIdentifier", command)) === COMMUNITY_APP_BUNDLE_ID;
592
+ }
593
+
594
+ async function validateBundleLayout(bundlePath: string): Promise<boolean> {
595
+ const bundleReal = await fs.realpath(bundlePath).catch(() => undefined);
596
+ if (!bundleReal) return false;
597
+ const contents = path.join(bundlePath, "Contents");
598
+ const contentsReal = await fs.realpath(contents).catch(() => undefined);
599
+ if (!contentsReal || contentsReal !== path.join(bundleReal, "Contents")) return false;
600
+ const infoPlist = path.join(contents, "Info.plist");
601
+ const infoReal = await fs.realpath(infoPlist).catch(() => undefined);
602
+ if (!infoReal || infoReal !== path.join(contentsReal, "Info.plist")) return false;
603
+ const macOSRoot = path.join(contents, "MacOS");
604
+ const macOSRootReal = await fs.realpath(macOSRoot).catch(() => undefined);
605
+ if (!macOSRootReal || macOSRootReal !== path.join(contentsReal, "MacOS")) return false;
606
+ const [bundleStat, contentsStat, infoStat, macOSRootStat] = await Promise.all([
607
+ fs.lstat(bundlePath).catch(() => undefined),
608
+ fs.lstat(contents).catch(() => undefined),
609
+ fs.lstat(infoPlist).catch(() => undefined),
610
+ fs.lstat(macOSRoot).catch(() => undefined),
611
+ ]);
612
+ if (
613
+ !(
614
+ bundleStat?.isDirectory() &&
615
+ contentsStat?.isDirectory() &&
616
+ infoStat?.isFile() &&
617
+ macOSRootStat?.isDirectory() &&
618
+ !bundleStat.isSymbolicLink() &&
619
+ !contentsStat.isSymbolicLink() &&
620
+ !infoStat.isSymbolicLink() &&
621
+ !macOSRootStat.isSymbolicLink()
622
+ )
623
+ )
624
+ return false;
625
+ const walk = async (directory: string): Promise<boolean> => {
626
+ const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => undefined);
627
+ if (!entries) return false;
628
+ for (const entry of entries) {
629
+ const entryPath = path.join(directory, entry.name);
630
+ const stat = await fs.lstat(entryPath).catch(() => undefined);
631
+ if (!stat) return false;
632
+ if (stat.isSymbolicLink()) {
633
+ const linkTarget = await fs.readlink(entryPath).catch(() => undefined);
634
+ if (!linkTarget || path.isAbsolute(linkTarget)) return false;
635
+ const resolved = await fs.realpath(entryPath).catch(() => undefined);
636
+ if (!resolved || (resolved !== bundleReal && !resolved.startsWith(`${bundleReal}${path.sep}`)))
637
+ return false;
638
+ continue;
639
+ }
640
+ if (stat.isDirectory() && !(await walk(entryPath))) return false;
641
+ }
642
+ return true;
643
+ };
644
+ return walk(bundlePath);
645
+ }
646
+
647
+ async function resolveVerifiedExecutable(bundlePath: string, executable: string): Promise<string | undefined> {
648
+ if (executable.length === 0 || path.basename(executable) !== executable || executable.includes("\\"))
649
+ return undefined;
650
+ if (!(await validateBundleLayout(bundlePath))) return undefined;
651
+ const macOSRoot = path.join(bundlePath, "Contents", "MacOS");
652
+ const executablePath = path.resolve(macOSRoot, executable);
653
+ if (path.dirname(executablePath) !== path.resolve(macOSRoot)) return undefined;
654
+ const [rootStat, executableStat] = await Promise.all([
655
+ fs.lstat(macOSRoot).catch(() => undefined),
656
+ fs.lstat(executablePath).catch(() => undefined),
657
+ ]);
658
+ if (
659
+ !rootStat?.isDirectory() ||
660
+ rootStat.isSymbolicLink() ||
661
+ !executableStat?.isFile() ||
662
+ executableStat.isSymbolicLink()
663
+ ) {
664
+ return undefined;
665
+ }
666
+ return executablePath;
667
+ }
668
+
669
+ async function hasExpectedDeveloperIdSignature(bundlePath: string, command: CommandRunner): Promise<boolean> {
670
+ const result = await command([
671
+ "/usr/bin/codesign",
672
+ "--verify",
673
+ "--deep",
674
+ "--strict",
675
+ "-R",
676
+ `=${COMMUNITY_APP_SIGNING_REQUIREMENT}`,
677
+ bundlePath,
678
+ ]);
679
+ requireReapedVerifier(result);
680
+ return result.exitCode === 0;
681
+ }
682
+
683
+ async function isVerifiedCommunityApp(
684
+ bundlePath: string,
685
+ arch: "arm64" | "x64",
686
+ command: CommandRunner,
687
+ ): Promise<boolean> {
688
+ if (!(await isExpectedBundle(bundlePath, command))) return false;
689
+ const executable = await readBundleValue(bundlePath, "CFBundleExecutable", command);
690
+ const executablePath = executable ? await resolveVerifiedExecutable(bundlePath, executable) : undefined;
691
+ if (!executablePath) return false;
692
+ if (!(await hasExpectedDeveloperIdSignature(bundlePath, command))) return false;
693
+ const policyAssessment = await command(["/usr/sbin/spctl", "--assess", "--type", "execute", bundlePath]);
694
+ requireReapedVerifier(policyAssessment);
695
+ if (policyAssessment.exitCode !== 0) return false;
696
+ const archCheck = await command(["/usr/bin/lipo", "-archs", executablePath]);
697
+ requireReapedVerifier(archCheck);
698
+ const executableArch = arch === "x64" ? "x86_64" : arch;
699
+ return (
700
+ archCheck.exitCode === 0 && archCheck.reaped !== false && archCheck.stdout.split(/\s+/).includes(executableArch)
701
+ );
702
+ }
703
+
704
+ async function findInstalledApp(
705
+ homeDir: string,
706
+ arch: "arm64" | "x64",
707
+ command: CommandRunner,
708
+ ): Promise<string | undefined> {
709
+ const applicationRoots = await Promise.all(
710
+ [path.join(homeDir, "Applications"), "/Applications"].map(root => fs.realpath(root).catch(() => undefined)),
711
+ );
712
+ const verifiedInstalledCandidate = async (candidate: string): Promise<string | undefined> => {
713
+ const canonical = await fs.realpath(candidate).catch(() => undefined);
714
+ if (!canonical) return undefined;
715
+ const beneathApplications = applicationRoots.some(root => {
716
+ if (!root) return false;
717
+ const relative = path.relative(root, canonical);
718
+ return (
719
+ relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)
720
+ );
721
+ });
722
+ if (!beneathApplications) return undefined;
723
+ return (await isVerifiedCommunityApp(canonical, arch, command)) ? canonical : undefined;
724
+ };
725
+ const candidates = [
726
+ path.join(homeDir, "Applications", "Gajae Code App.app"),
727
+ path.join(homeDir, "Applications", "Gajae-Code-App.app"),
728
+ "/Applications/Gajae Code App.app",
729
+ "/Applications/Gajae-Code-App.app",
730
+ ];
731
+ for (const candidate of candidates) {
732
+ const installed = await verifiedInstalledCandidate(candidate);
733
+ if (installed) return installed;
734
+ }
735
+ const result = await command(["/usr/bin/mdfind", `kMDItemCFBundleIdentifier == '${COMMUNITY_APP_BUNDLE_ID}'`]);
736
+ if (result.reaped === false) throw new Error("installed app discovery helper did not terminate safely");
737
+ if (result.exitCode !== 0) return undefined;
738
+ for (const candidate of result.stdout
739
+ .split(/\r?\n/)
740
+ .map(line => line.trim())
741
+ .filter(Boolean)) {
742
+ const installed = await verifiedInstalledCandidate(candidate);
743
+ if (installed) return installed;
744
+ }
745
+ return undefined;
746
+ }
747
+
748
+ async function defaultPrompt(signal?: AbortSignal): Promise<boolean> {
749
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
750
+ const interrupted = Promise.withResolvers<string>();
751
+ const onAbort = () => {
752
+ readline.close();
753
+ interrupted.reject(new Error("community app offer interrupted"));
754
+ };
755
+ if (signal?.aborted) onAbort();
756
+ else signal?.addEventListener("abort", onAbort, { once: true });
757
+ try {
758
+ const answer = await Promise.race([
759
+ readline.question("Install Gajae Code App (experimental, community-built)? [y/N] "),
760
+ interrupted.promise,
761
+ ]);
762
+ return /^y(?:es)?$/i.test(answer.trim());
763
+ } finally {
764
+ signal?.removeEventListener("abort", onAbort);
765
+ readline.close();
766
+ }
767
+ }
768
+
769
+ function failure(reason: string, log: (message: string) => void): CommunityAppOfferResult {
770
+ log(`Optional community app offer unavailable: ${reason}. See https://github.com/${COMMUNITY_APP_REPOSITORY}`);
771
+ return { status: "failed", reason };
772
+ }
773
+
774
+ function environmentFlagEnabled(value: string | undefined): boolean {
775
+ if (!value) return false;
776
+ const normalized = value.trim().toLowerCase();
777
+ return (
778
+ normalized !== "" && normalized !== "0" && normalized !== "false" && normalized !== "no" && normalized !== "off"
779
+ );
780
+ }
781
+
782
+ function signalExitCode(signal: "SIGINT" | "SIGTERM" | "SIGHUP"): number {
783
+ switch (signal) {
784
+ case "SIGINT":
785
+ return 130;
786
+ case "SIGTERM":
787
+ return 143;
788
+ case "SIGHUP":
789
+ return 129;
790
+ }
791
+ }
792
+
793
+ export async function offerMacosCommunityApp(
794
+ deps: CommunityAppOfferDependencies = {},
795
+ ): Promise<CommunityAppOfferResult> {
796
+ const platform = deps.platform ?? process.platform;
797
+ const env = deps.env ?? process.env;
798
+ // Sanitize only at the display boundary; filesystem paths and result data stay exact.
799
+ const log = (message: string) => {
800
+ const safe = sanitizeDisplayLine(message).replace(/[\t\u2028\u2029\u202a-\u202e\u2066-\u2069]/gu, " ");
801
+ deps.log?.(safe.length > MAX_LOG_CHARS ? `${safe.slice(0, MAX_LOG_CHARS - 3)}...` : safe);
802
+ };
803
+ if (platform !== "darwin") return { status: "skipped", reason: "unsupported platform" };
804
+ if (environmentFlagEnabled(env[COMMUNITY_APP_SUPPRESS_ENV])) {
805
+ return { status: "skipped", reason: "suppressed by environment" };
806
+ }
807
+ if (
808
+ environmentFlagEnabled(env.CI) ||
809
+ environmentFlagEnabled(env.GITHUB_ACTIONS) ||
810
+ environmentFlagEnabled(env.GJC_NONINTERACTIVE) ||
811
+ environmentFlagEnabled(env.NONINTERACTIVE) ||
812
+ environmentFlagEnabled(env.npm_lifecycle_event) ||
813
+ environmentFlagEnabled(env.npm_command)
814
+ ) {
815
+ return { status: "skipped", reason: "automation environment" };
816
+ }
817
+ if (
818
+ deps.stdinIsTTY === false ||
819
+ deps.stdoutIsTTY === false ||
820
+ (!deps.stdinIsTTY && !process.stdin.isTTY) ||
821
+ (!deps.stdoutIsTTY && !process.stdout.isTTY)
822
+ ) {
823
+ return { status: "skipped", reason: "non-interactive terminal" };
824
+ }
825
+
826
+ let receivedSignal: "SIGINT" | "SIGTERM" | "SIGHUP" | undefined;
827
+ let cleanupUnsafe = false;
828
+ const providedCommand = deps.command;
829
+ const rawCommand: CommandRunner = providedCommand ?? runCommand;
830
+ const cleanupCommand = deps.cleanupCommand ?? runCommand;
831
+ const command: CommandRunner = async argv => {
832
+ if (receivedSignal) throw new Error(`interrupted by ${receivedSignal}`);
833
+ const result = await rawCommand(argv);
834
+ if (result.reaped === false) cleanupUnsafe = true;
835
+ return result;
836
+ };
837
+ const signalNames = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
838
+ const signalHandlers = new Map<NodeJS.Signals, () => void>();
839
+ const offerSettled = Promise.withResolvers<void>();
840
+ const fetchAbortController = new AbortController();
841
+ const fetchAbortSignal = deps.signal
842
+ ? AbortSignal.any([deps.signal, fetchAbortController.signal])
843
+ : fetchAbortController.signal;
844
+ const onSignal = (signal: "SIGINT" | "SIGTERM" | "SIGHUP") => {
845
+ receivedSignal ??= signal;
846
+ fetchAbortController.abort();
847
+ for (const controller of activeCommandControllers) controller.abort();
848
+ };
849
+ for (const signal of signalNames) {
850
+ const handler = () => onSignal(signal);
851
+ signalHandlers.set(signal, handler);
852
+ process.on(signal, handler);
853
+ }
854
+ const removeSignalHandlers = () => {
855
+ for (const signal of signalNames) {
856
+ const handler = signalHandlers.get(signal);
857
+ if (handler) process.removeListener(signal, handler);
858
+ }
859
+ };
860
+ const unregisterPostmortemCleanup = postmortem.register("macos-community-app-offer", async reason => {
861
+ if (reason === postmortem.Reason.SIGINT) onSignal("SIGINT");
862
+ else if (reason === postmortem.Reason.SIGTERM) onSignal("SIGTERM");
863
+ else if (reason === postmortem.Reason.SIGHUP) onSignal("SIGHUP");
864
+ await offerSettled.promise;
865
+ });
866
+ const finishOwnership = (result: CommunityAppOfferResult): CommunityAppOfferResult => {
867
+ offerSettled.resolve();
868
+ unregisterPostmortemCleanup();
869
+ removeSignalHandlers();
870
+ if (receivedSignal) process.exitCode = signalExitCode(receivedSignal);
871
+ return result;
872
+ };
873
+ const homeDir = deps.homeDir ?? os.homedir();
874
+ const arch = deps.arch ?? process.arch;
875
+ if (!isMacArchitecture(arch)) return finishOwnership(failure(`unsupported macOS architecture ${arch}`, log));
876
+ try {
877
+ if (await findInstalledApp(homeDir, arch, command))
878
+ return finishOwnership({ status: "skipped", reason: "already installed" });
879
+ log(
880
+ `Gajae Code App is experimental, community-built THIRD-PARTY software, separately licensed, with no first-party support. Canonical repository: https://github.com/${COMMUNITY_APP_REPOSITORY}`,
881
+ );
882
+ if (!(await (deps.prompt ?? (() => defaultPrompt(fetchAbortSignal)))()))
883
+ return finishOwnership({ status: "skipped", reason: "cancelled" });
884
+ } catch (error) {
885
+ return finishOwnership(failure(error instanceof Error ? error.message : String(error), log));
886
+ }
887
+
888
+ const fetchImpl = deps.fetchImpl ?? fetch;
889
+ const fetchOptions = (timeoutMs: number): RequestInit => ({
890
+ signal: AbortSignal.any([fetchAbortSignal, AbortSignal.timeout(timeoutMs)]),
891
+ });
892
+ let tempRoot: string | undefined;
893
+ let tempRootIdentity: FileIdentity | undefined;
894
+ let mountPoint: string | undefined;
895
+ let mountIdentity: FileIdentity | undefined;
896
+ let mountAttempted = false;
897
+ let mountState: "none" | "unknown" | "attached" = "none";
898
+ let installedDestination: { path: string; identity: FileIdentity } | undefined;
899
+ let priorDestination: { originalPath: string; backupPath: string; identity: FileIdentity } | undefined;
900
+ let launchCommitted = false;
901
+ let destinationRoot: string | undefined;
902
+ let destinationRootIdentity: FileIdentity | undefined;
903
+ let releaseDestinationLock: (() => Promise<void>) | undefined;
904
+ const throwIfInterrupted = () => {
905
+ if (receivedSignal) throw new Error(`community app offer interrupted by ${receivedSignal}`);
906
+ if (fetchAbortSignal.aborted) throw new Error("community app offer interrupted");
907
+ };
908
+ try {
909
+ const releaseResponse = await fetchImpl(
910
+ `${GITHUB_API_ORIGIN}/repos/${COMMUNITY_APP_REPOSITORY}/releases?per_page=20`,
911
+ {
912
+ headers: {
913
+ Accept: "application/vnd.github+json",
914
+ "User-Agent": "gjc-community-app-offer",
915
+ "X-GitHub-Api-Version": "2022-11-28",
916
+ },
917
+ signal: fetchOptions(METADATA_FETCH_TIMEOUT_MS).signal,
918
+ },
919
+ );
920
+ if (!releaseResponse.ok)
921
+ return failure(`no canonical published release is available (HTTP ${releaseResponse.status})`, log);
922
+ const payload = JSON.parse(await readResponseText(releaseResponse, MAX_RELEASE_BYTES)) as
923
+ | ReleasePayload
924
+ | ReleasePayload[];
925
+ const releases = Array.isArray(payload) ? payload : [payload];
926
+ throwIfInterrupted();
927
+ const release = releases.find(candidate => {
928
+ const tagVersion = releaseVersion(candidate.tag_name);
929
+ return Boolean(tagVersion && !candidate.draft && candidate.assets?.length);
930
+ });
931
+ if (!release) return failure("no canonical published release is available", log);
932
+ const tagVersion = releaseVersion(release.tag_name);
933
+ if (!tagVersion || !release.assets?.length) return failure("no canonical published release is available", log);
934
+ const dmg = release.assets.find(
935
+ asset => asset.name.toLowerCase().endsWith(".dmg") && assetMatchesArchitecture(asset.name, arch, tagVersion),
936
+ );
937
+ if (!dmg || !trustedReleaseAssetUrl(dmg.browser_download_url, dmg.name, release.tag_name!))
938
+ return failure(`no verified macOS ${arch} DMG is published`, log);
939
+ const checksumAsset = release.assets.find(
940
+ asset =>
941
+ asset.name === `${dmg.name}.sha256` || (asset.name.endsWith(".sha256") && asset.name.includes(dmg.name)),
942
+ );
943
+ if (
944
+ !checksumAsset ||
945
+ !trustedReleaseAssetUrl(checksumAsset.browser_download_url, checksumAsset.name, release.tag_name!)
946
+ )
947
+ return failure("the release has no trusted DMG checksum", log);
948
+ const assetAbortController = new AbortController();
949
+ const assetFetchOptions = (timeoutMs: number): RequestInit => ({
950
+ signal: AbortSignal.any([fetchAbortSignal, assetAbortController.signal, AbortSignal.timeout(timeoutMs)]),
951
+ });
952
+ let dmgResponse: Response | undefined;
953
+ let checksumResponse: Response | undefined;
954
+ try {
955
+ await Promise.all([
956
+ fetchImpl(dmg.browser_download_url, assetFetchOptions(ASSET_FETCH_TIMEOUT_MS)).then(response => {
957
+ dmgResponse = response;
958
+ }),
959
+ fetchImpl(checksumAsset.browser_download_url, assetFetchOptions(METADATA_FETCH_TIMEOUT_MS)).then(
960
+ response => {
961
+ checksumResponse = response;
962
+ },
963
+ ),
964
+ ]);
965
+ } catch (error) {
966
+ assetAbortController.abort();
967
+ await Promise.all([
968
+ dmgResponse?.body?.cancel().catch(() => undefined),
969
+ checksumResponse?.body?.cancel().catch(() => undefined),
970
+ ]);
971
+ throw error;
972
+ }
973
+ if (!dmgResponse || !checksumResponse) throw new Error("release asset fetches did not settle conclusively");
974
+ if (!dmgResponse.ok || !checksumResponse.ok) {
975
+ assetAbortController.abort();
976
+ await Promise.all([
977
+ dmgResponse.body?.cancel().catch(() => undefined),
978
+ checksumResponse.body?.cancel().catch(() => undefined),
979
+ ]);
980
+ return failure("the canonical release assets could not be downloaded", log);
981
+ }
982
+ let expected: string | undefined;
983
+ try {
984
+ expected = parseChecksum(await readResponseText(checksumResponse, MAX_CHECKSUM_BYTES), dmg.name);
985
+ } catch (error) {
986
+ assetAbortController.abort();
987
+ await dmgResponse.body?.cancel().catch(() => undefined);
988
+ throw error;
989
+ }
990
+ throwIfInterrupted();
991
+ if (!expected) {
992
+ assetAbortController.abort();
993
+ await dmgResponse.body?.cancel().catch(() => undefined);
994
+ return failure("the published checksum does not name the DMG", log);
995
+ }
996
+
997
+ tempRoot = await fs.mkdtemp(path.join(deps.tempDir ?? os.tmpdir(), "gjc-community-app-"));
998
+ tempRootIdentity = await fileIdentity(tempRoot);
999
+ if (!tempRootIdentity) return failure("the temporary root was not a real directory", log);
1000
+ const dmgPath = path.join(tempRoot, dmg.name);
1001
+ mountPoint = path.join(tempRoot, "mount");
1002
+ if (!(await sameDirectoryIdentity(tempRoot, tempRootIdentity)))
1003
+ return failure("the temporary root identity changed", log);
1004
+ await fs.mkdir(mountPoint);
1005
+ const dmgHandle = await fs.open(dmgPath, "wx");
1006
+ let actual: string;
1007
+ try {
1008
+ actual = await writeResponseToFileAndHash(dmgResponse, dmgHandle, MAX_DMG_BYTES);
1009
+ } finally {
1010
+ await dmgHandle.close();
1011
+ }
1012
+ if (actual !== expected) return failure("the DMG checksum did not match", log);
1013
+ const stagedDmgStat = await fs.lstat(dmgPath, { bigint: true }).catch(() => undefined);
1014
+ if (!stagedDmgStat?.isFile() || stagedDmgStat.isSymbolicLink())
1015
+ return failure("the staged DMG path was unsafe", log);
1016
+ const stagedDmgIdentity: FileIdentity = { dev: stagedDmgStat.dev, ino: stagedDmgStat.ino };
1017
+ if (!(await samePathIdentity(dmgPath, stagedDmgIdentity))) return failure("the staged DMG identity changed", log);
1018
+ if (!(await sameDirectoryIdentity(tempRoot, tempRootIdentity)))
1019
+ return failure("the temporary root identity changed before attach", log);
1020
+ throwIfInterrupted();
1021
+ const mountPointBeforeAttachIdentity = await fileIdentity(mountPoint);
1022
+ mountAttempted = true;
1023
+ mountState = "unknown";
1024
+ let attach: CommandResult;
1025
+ try {
1026
+ attach = await command([
1027
+ "/usr/bin/hdiutil",
1028
+ "attach",
1029
+ "-nobrowse",
1030
+ "-readonly",
1031
+ "-mountpoint",
1032
+ mountPoint,
1033
+ dmgPath,
1034
+ ]);
1035
+ } catch (error) {
1036
+ const observedMountIdentity = await fileIdentity(mountPoint);
1037
+ const mountChanged =
1038
+ observedMountIdentity &&
1039
+ mountPointBeforeAttachIdentity &&
1040
+ (observedMountIdentity.dev !== mountPointBeforeAttachIdentity.dev ||
1041
+ observedMountIdentity.ino !== mountPointBeforeAttachIdentity.ino);
1042
+ mountIdentity = mountChanged ? observedMountIdentity : undefined;
1043
+ mountState = mountChanged
1044
+ ? "attached"
1045
+ : observedMountIdentity && mountPointBeforeAttachIdentity
1046
+ ? "none"
1047
+ : "unknown";
1048
+ throw error;
1049
+ }
1050
+ if (attach.reaped === false) {
1051
+ mountIdentity = undefined;
1052
+ mountState = "unknown";
1053
+ return failure("the DMG attachment helper did not terminate safely", log);
1054
+ }
1055
+ const observedMountIdentity = await fileIdentity(mountPoint);
1056
+ const mountChanged =
1057
+ observedMountIdentity &&
1058
+ mountPointBeforeAttachIdentity &&
1059
+ (observedMountIdentity.dev !== mountPointBeforeAttachIdentity.dev ||
1060
+ observedMountIdentity.ino !== mountPointBeforeAttachIdentity.ino);
1061
+ mountIdentity = attach.exitCode === 0 || mountChanged ? observedMountIdentity : undefined;
1062
+ mountState =
1063
+ attach.exitCode === 0
1064
+ ? mountIdentity
1065
+ ? "attached"
1066
+ : "unknown"
1067
+ : mountChanged
1068
+ ? "attached"
1069
+ : observedMountIdentity && mountPointBeforeAttachIdentity
1070
+ ? "none"
1071
+ : "unknown";
1072
+ throwIfInterrupted();
1073
+ if (attach.exitCode !== 0) return failure("the DMG could not be mounted safely", log);
1074
+ if (!mountIdentity) return failure("the mounted volume was not a real directory", log);
1075
+ if (!(await samePathIdentity(dmgPath, stagedDmgIdentity)))
1076
+ return failure("the staged DMG identity changed before verification", log);
1077
+ if (!(await sameDirectoryIdentity(mountPoint, mountIdentity)))
1078
+ return failure("the temporary mountpoint identity changed", log);
1079
+ const entries = await fs.readdir(mountPoint, { withFileTypes: true });
1080
+ const appEntry = entries.find(entry => entry.isDirectory() && entry.name.endsWith(".app"));
1081
+ if (!appEntry) return failure("the mounted DMG contained no app bundle", log);
1082
+ const sourceApp = path.join(mountPoint, appEntry.name);
1083
+ if (!(await isExpectedBundle(sourceApp, command)))
1084
+ return failure(`the app bundle identifier was not ${COMMUNITY_APP_BUNDLE_ID}`, log);
1085
+ const executable = await readBundleValue(sourceApp, "CFBundleExecutable", command);
1086
+ if (!executable) return failure("the app bundle has no executable identity", log);
1087
+ const executablePath = await resolveVerifiedExecutable(sourceApp, executable);
1088
+ if (!executablePath) return failure("the app bundle executable path was unsafe", log);
1089
+ if (!(await hasExpectedDeveloperIdSignature(sourceApp, command)))
1090
+ return failure("the app bundle was signed by an unexpected publisher", log);
1091
+ const policyAssessment = await command(["/usr/sbin/spctl", "--assess", "--type", "execute", sourceApp]);
1092
+ if (policyAssessment.reaped === false) {
1093
+ cleanupUnsafe = true;
1094
+ return failure("Gatekeeper helper did not terminate safely", log);
1095
+ }
1096
+ if (policyAssessment.exitCode !== 0) return failure("Gatekeeper rejected the app bundle", log);
1097
+ const archCheck = await command(["/usr/bin/lipo", "-archs", executablePath]);
1098
+ const executableArch = arch === "x64" ? "x86_64" : arch;
1099
+ if (archCheck.reaped === false) {
1100
+ cleanupUnsafe = true;
1101
+ return failure("architecture helper did not terminate safely", log);
1102
+ }
1103
+ if (archCheck.exitCode !== 0 || !archCheck.stdout.split(/\s+/).includes(executableArch))
1104
+ return failure(`the app bundle does not contain ${arch} code`, log);
1105
+ if (!mountIdentity || !(await sameDirectoryIdentity(mountPoint, mountIdentity)))
1106
+ return failure("the mounted volume identity changed before copy", log);
1107
+ if (!(await isExpectedBundle(sourceApp, command)))
1108
+ return failure("the mounted app bundle identity changed before copy", log);
1109
+
1110
+ destinationRoot = path.join(homeDir, "Applications");
1111
+ const currentDestinationRoot = destinationRoot;
1112
+ const existingDestinationRoot = await fs.lstat(currentDestinationRoot).catch(() => undefined);
1113
+ if (
1114
+ existingDestinationRoot &&
1115
+ (!existingDestinationRoot.isDirectory() || existingDestinationRoot.isSymbolicLink())
1116
+ )
1117
+ return failure("the Applications destination is not a real directory", log);
1118
+ await fs.mkdir(currentDestinationRoot, { recursive: true });
1119
+ const destinationRootStat = await fs.lstat(currentDestinationRoot);
1120
+ if (!destinationRootStat.isDirectory() || destinationRootStat.isSymbolicLink())
1121
+ return failure("the Applications destination is not a real directory", log);
1122
+ destinationRootIdentity = await fileIdentity(currentDestinationRoot);
1123
+ if (!destinationRootIdentity) return failure("the Applications destination identity was unavailable", log);
1124
+ const destination = path.join(currentDestinationRoot, appEntry.name);
1125
+ if (!(await sameDirectoryIdentity(currentDestinationRoot, destinationRootIdentity)))
1126
+ return failure("the Applications destination identity changed", log);
1127
+ throwIfInterrupted();
1128
+ const existingDestination = await fs.lstat(destination).catch(() => undefined);
1129
+ if (existingDestination && (!existingDestination.isDirectory() || existingDestination.isSymbolicLink()))
1130
+ return failure("the destination already contains an unsafe path", log);
1131
+ const existingDestinationIdentity = existingDestination ? await fileIdentity(destination) : undefined;
1132
+ if (existingDestination && !existingDestinationIdentity)
1133
+ return failure("the existing destination identity was unavailable", log);
1134
+ const stagingDestination = path.join(currentDestinationRoot, `.${appEntry.name}.${randomUUID()}.tmp`);
1135
+ await fs.mkdir(stagingDestination);
1136
+ const stagingIdentity = await fileIdentity(stagingDestination);
1137
+ if (!stagingIdentity) throw new Error("the staging destination claim was not a regular directory");
1138
+ installedDestination = { path: stagingDestination, identity: stagingIdentity };
1139
+ if (
1140
+ !(await sameDirectoryIdentity(currentDestinationRoot, destinationRootIdentity)) ||
1141
+ !(await sameDirectoryIdentity(stagingDestination, stagingIdentity))
1142
+ )
1143
+ throw new Error("the staging destination identity changed before copy");
1144
+ throwIfInterrupted();
1145
+ if (!mountIdentity || !(await sameDirectoryIdentity(mountPoint, mountIdentity)))
1146
+ throw new Error("the mounted volume identity changed before copy");
1147
+ if (!(await isExpectedBundle(sourceApp, command))) throw new Error("the mounted app bundle changed before copy");
1148
+ const copy = await command(["/usr/bin/ditto", sourceApp, stagingDestination]);
1149
+ throwIfInterrupted();
1150
+ if (copy.reaped === false) {
1151
+ cleanupUnsafe = true;
1152
+ throw new Error("copy helper did not terminate safely");
1153
+ }
1154
+ if (copy.exitCode !== 0) throw new Error("copying the verified app bundle failed");
1155
+ if (
1156
+ !(await sameDirectoryIdentity(currentDestinationRoot, destinationRootIdentity)) ||
1157
+ !(await sameDirectoryIdentity(stagingDestination, stagingIdentity))
1158
+ )
1159
+ throw new Error("the staging destination identity changed after copy");
1160
+ if (!(await isExpectedBundle(stagingDestination, command)))
1161
+ throw new Error("the copied app bundle identity changed");
1162
+ const copiedExecutable = await readBundleValue(stagingDestination, "CFBundleExecutable", command);
1163
+ const copiedExecutablePath = copiedExecutable
1164
+ ? await resolveVerifiedExecutable(stagingDestination, copiedExecutable)
1165
+ : undefined;
1166
+ if (!copiedExecutable || copiedExecutable !== executable || !copiedExecutablePath)
1167
+ throw new Error("the copied app executable identity changed");
1168
+ if (!(await hasExpectedDeveloperIdSignature(stagingDestination, command)))
1169
+ throw new Error("the copied app was signed by an unexpected publisher");
1170
+ const copiedPolicyAssessment = await command([
1171
+ "/usr/sbin/spctl",
1172
+ "--assess",
1173
+ "--type",
1174
+ "execute",
1175
+ stagingDestination,
1176
+ ]);
1177
+ if (copiedPolicyAssessment.reaped === false) {
1178
+ cleanupUnsafe = true;
1179
+ throw new Error("copied app Gatekeeper helper did not terminate safely");
1180
+ }
1181
+ if (copiedPolicyAssessment.exitCode !== 0) throw new Error("Gatekeeper rejected the copied app bundle");
1182
+ const copiedArchCheck = await command(["/usr/bin/lipo", "-archs", copiedExecutablePath]);
1183
+ if (copiedArchCheck.reaped === false) {
1184
+ cleanupUnsafe = true;
1185
+ throw new Error("copied app architecture helper did not terminate safely");
1186
+ }
1187
+ if (copiedArchCheck.exitCode !== 0 || !copiedArchCheck.stdout.split(/\s+/).includes(executableArch))
1188
+ throw new Error("the copied app architecture changed");
1189
+ // Copy and verify privately; serialize only publication, launch, and rollback.
1190
+ releaseDestinationLock = await acquireFileLock(destination, {
1191
+ retries: 6_000,
1192
+ retryDelayMs: 100,
1193
+ signal: fetchAbortSignal,
1194
+ });
1195
+ throwIfInterrupted();
1196
+ if (!(await sameDirectoryIdentity(currentDestinationRoot, destinationRootIdentity)))
1197
+ throw new Error("the Applications destination identity changed before publication");
1198
+ const currentExistingDestination = await fs.lstat(destination).catch(() => undefined);
1199
+ if (
1200
+ currentExistingDestination &&
1201
+ (!currentExistingDestination.isDirectory() || currentExistingDestination.isSymbolicLink())
1202
+ )
1203
+ throw new Error("the destination changed to an unsafe path before replacement");
1204
+ const currentExistingDestinationIdentity = currentExistingDestination
1205
+ ? await fileIdentity(destination)
1206
+ : undefined;
1207
+ const destinationChanged = existingDestinationIdentity
1208
+ ? !currentExistingDestinationIdentity ||
1209
+ currentExistingDestinationIdentity.dev !== existingDestinationIdentity.dev ||
1210
+ currentExistingDestinationIdentity.ino !== existingDestinationIdentity.ino
1211
+ : Boolean(currentExistingDestinationIdentity);
1212
+ if (destinationChanged && !currentExistingDestinationIdentity)
1213
+ throw new Error("the existing destination identity changed before replacement");
1214
+ if (currentExistingDestinationIdentity) {
1215
+ if (!(await sameDirectoryIdentity(destination, currentExistingDestinationIdentity)))
1216
+ throw new Error("the existing destination identity changed before replacement");
1217
+ if (await isVerifiedCommunityApp(destination, arch, command)) {
1218
+ if (
1219
+ !(await sameDirectoryIdentity(currentDestinationRoot, destinationRootIdentity)) ||
1220
+ !(await sameDirectoryIdentity(destination, currentExistingDestinationIdentity))
1221
+ )
1222
+ throw new Error("the verified concurrent destination identity changed");
1223
+ try {
1224
+ const stagedRemoved = await removeClaimedDirectory(
1225
+ stagingDestination,
1226
+ stagingIdentity,
1227
+ currentDestinationRoot,
1228
+ destinationRootIdentity,
1229
+ log,
1230
+ );
1231
+ if (!stagedRemoved)
1232
+ throw new Error(
1233
+ "the staged app bundle could not be removed after detecting an existing verified installation",
1234
+ );
1235
+ } catch (error) {
1236
+ throw new Error(
1237
+ `concurrent verified installation detected but staged bundle cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
1238
+ );
1239
+ }
1240
+ installedDestination = undefined;
1241
+ return { status: "skipped", reason: "already installed" };
1242
+ }
1243
+ if (destinationChanged) throw new Error("the concurrent destination was not a verified community app");
1244
+ const backupPath = path.join(currentDestinationRoot, `.${appEntry.name}.${randomUUID()}.previous`);
1245
+ await fs.rename(destination, backupPath);
1246
+ priorDestination = { originalPath: destination, backupPath, identity: currentExistingDestinationIdentity };
1247
+ if (
1248
+ !(await sameDirectoryIdentity(currentDestinationRoot, destinationRootIdentity)) ||
1249
+ !(await sameDirectoryIdentity(backupPath, currentExistingDestinationIdentity))
1250
+ )
1251
+ throw new Error("the prior app bundle identity changed during replacement staging");
1252
+ }
1253
+ await fs.rename(stagingDestination, destination);
1254
+ installedDestination = { path: destination, identity: stagingIdentity };
1255
+ const destinationIdentity = await fileIdentity(destination);
1256
+ if (!destinationIdentity) throw new Error("the installed destination identity was unavailable");
1257
+ if (!(await sameDirectoryIdentity(destination, stagingIdentity)))
1258
+ throw new Error("the published app identity changed");
1259
+ if (
1260
+ !(await sameDirectoryIdentity(currentDestinationRoot, destinationRootIdentity)) ||
1261
+ !(await sameDirectoryIdentity(destination, destinationIdentity))
1262
+ )
1263
+ throw new Error("the destination identity changed before launch");
1264
+ throwIfInterrupted();
1265
+ const launch = await command(["/usr/bin/open", destination]);
1266
+ if (launch.reaped === false) {
1267
+ cleanupUnsafe = true;
1268
+ return failure(
1269
+ "the app launch helper did not terminate safely; partial app state was retained for safety",
1270
+ log,
1271
+ );
1272
+ }
1273
+ if (launch.exitCode !== 0) throw new Error("the verified app could not be launched");
1274
+ launchCommitted = true;
1275
+ if (priorDestination) {
1276
+ const removed = await removeClaimedDirectory(
1277
+ priorDestination.backupPath,
1278
+ priorDestination.identity,
1279
+ currentDestinationRoot,
1280
+ destinationRootIdentity,
1281
+ log,
1282
+ retainedPath => {
1283
+ priorDestination!.backupPath = retainedPath;
1284
+ },
1285
+ );
1286
+ if (!removed) {
1287
+ log(
1288
+ `Optional community app cleanup warning: replacement retained at ${destination}; prior backup cleanup state retained at ${priorDestination.backupPath}`,
1289
+ );
1290
+ return {
1291
+ status: "installed",
1292
+ reason: `${destination}; prior backup cleanup state retained at ${priorDestination.backupPath}`,
1293
+ };
1294
+ }
1295
+ priorDestination = undefined;
1296
+ }
1297
+ return { status: "installed", reason: destination };
1298
+ } catch (error) {
1299
+ if (launchCommitted) {
1300
+ return failure(
1301
+ `launched replacement retained at ${installedDestination?.path}; prior backup cleanup state retained at ${priorDestination?.backupPath}: ${String(error)}`,
1302
+ log,
1303
+ );
1304
+ }
1305
+ let partialState = installedDestination ? "retained for safety" : "not created";
1306
+ if (installedDestination) {
1307
+ try {
1308
+ if (
1309
+ destinationRoot &&
1310
+ tempRoot &&
1311
+ destinationRootIdentity &&
1312
+ (await sameDirectoryIdentity(destinationRoot, destinationRootIdentity)) &&
1313
+ (await sameDirectoryIdentity(installedDestination.path, installedDestination.identity)) &&
1314
+ !cleanupUnsafe
1315
+ ) {
1316
+ const removed = await removeClaimedDirectory(
1317
+ installedDestination.path,
1318
+ installedDestination.identity,
1319
+ destinationRoot,
1320
+ destinationRootIdentity,
1321
+ log,
1322
+ );
1323
+ if (removed) partialState = "removed";
1324
+ }
1325
+ } catch (cleanupError) {
1326
+ log(`Optional community app cleanup warning: failed to remove partial app state: ${String(cleanupError)}`);
1327
+ }
1328
+ }
1329
+ if (priorDestination && destinationRoot && destinationRootIdentity && !cleanupUnsafe) {
1330
+ try {
1331
+ const destinationExists = await fs.lstat(priorDestination.originalPath).catch(() => undefined);
1332
+ if (
1333
+ !destinationExists &&
1334
+ (await sameDirectoryIdentity(destinationRoot, destinationRootIdentity)) &&
1335
+ (await sameDirectoryIdentity(priorDestination.backupPath, priorDestination.identity))
1336
+ ) {
1337
+ await fs.rename(priorDestination.backupPath, priorDestination.originalPath);
1338
+ if (await sameDirectoryIdentity(priorDestination.originalPath, priorDestination.identity)) {
1339
+ partialState = "removed and prior app restored";
1340
+ priorDestination = undefined;
1341
+ }
1342
+ }
1343
+ } catch (restoreError) {
1344
+ log(`Optional community app cleanup warning: failed to restore prior app: ${String(restoreError)}`);
1345
+ }
1346
+ }
1347
+ if (priorDestination)
1348
+ log(`Optional community app cleanup warning: prior app retained at ${priorDestination.backupPath}`);
1349
+ return failure(
1350
+ `${error instanceof Error ? error.message : String(error)}; partial app state ${partialState}`,
1351
+ log,
1352
+ );
1353
+ } finally {
1354
+ try {
1355
+ await releaseDestinationLock?.();
1356
+ } catch (error) {
1357
+ log(`Optional community app cleanup warning: failed to release destination lock: ${String(error)}`);
1358
+ }
1359
+ let removeTempRoot = true;
1360
+ if (mountAttempted && mountPoint) {
1361
+ try {
1362
+ if (cleanupUnsafe) {
1363
+ removeTempRoot = false;
1364
+ log(
1365
+ "Optional community app cleanup warning: a native helper did not terminate safely; retaining temporary state",
1366
+ );
1367
+ } else if (
1368
+ mountState === "attached" &&
1369
+ mountIdentity &&
1370
+ !(await sameDirectoryIdentity(mountPoint, mountIdentity))
1371
+ ) {
1372
+ removeTempRoot = false;
1373
+ log("Optional community app cleanup warning: mountpoint identity changed; refusing detach");
1374
+ } else if (mountState === "unknown") {
1375
+ removeTempRoot = false;
1376
+ log("Optional community app cleanup warning: mount identity was unavailable; refusing pathname detach");
1377
+ } else if (mountState === "none") {
1378
+ // The attach attempt completed without establishing a mount.
1379
+ } else {
1380
+ const detach = await cleanupCommand(
1381
+ ["/usr/bin/hdiutil", "detach", mountPoint, "-force"],
1382
+ receivedSignal || deps.signal?.aborted
1383
+ ? SIGNAL_CLEANUP_COMMAND_TIMEOUT_MS
1384
+ : CLEANUP_COMMAND_TIMEOUT_MS,
1385
+ );
1386
+ if (detach.reaped === false) cleanupUnsafe = true;
1387
+ if (detach.reaped === false) {
1388
+ removeTempRoot = false;
1389
+ log("Optional community app cleanup warning: detach helper did not terminate safely");
1390
+ } else if (detach.exitCode !== 0) {
1391
+ removeTempRoot = false;
1392
+ log("Optional community app cleanup warning: hdiutil could not detach the temporary DMG");
1393
+ } else {
1394
+ mountState = "none";
1395
+ }
1396
+ }
1397
+ } catch (error) {
1398
+ if (mountState !== "none") removeTempRoot = false;
1399
+ log(`Optional community app cleanup warning: failed to detach the temporary DMG: ${String(error)}`);
1400
+ }
1401
+ }
1402
+ if (removeTempRoot && tempRoot && tempRootIdentity && (await sameDirectoryIdentity(tempRoot, tempRootIdentity))) {
1403
+ try {
1404
+ await fs.rm(tempRoot, { recursive: true, force: true });
1405
+ } catch (error) {
1406
+ log(`Optional community app cleanup warning: failed to remove temporary files: ${String(error)}`);
1407
+ }
1408
+ } else if (tempRoot) {
1409
+ log("Optional community app cleanup warning: temporary root identity changed; refusing recursive removal");
1410
+ }
1411
+ offerSettled.resolve();
1412
+ unregisterPostmortemCleanup();
1413
+ removeSignalHandlers();
1414
+ if (receivedSignal) process.exitCode = signalExitCode(receivedSignal);
1415
+ }
1416
+ }
1417
+
1418
+ export function parseCommunityAppChecksumForTest(text: string, assetName: string): string | undefined {
1419
+ return parseChecksum(text, assetName);
1420
+ }
1421
+
1422
+ export function communityAppAssetMatchesArchitectureForTest(name: string, arch: "arm64" | "x64"): boolean {
1423
+ return assetMatchesArchitecture(name, arch);
1424
+ }
1425
+
1426
+ export async function resolveCommunityAppExecutableForTest(
1427
+ bundlePath: string,
1428
+ executable: string,
1429
+ ): Promise<string | undefined> {
1430
+ return resolveVerifiedExecutable(bundlePath, executable);
1431
+ }
1432
+
1433
+ export async function hasExpectedCommunityAppSignatureForTest(
1434
+ bundlePath: string,
1435
+ command: CommandRunner,
1436
+ ): Promise<boolean> {
1437
+ return hasExpectedDeveloperIdSignature(bundlePath, command);
1438
+ }
1439
+
1440
+ export async function runCommunityAppCommandForTest(
1441
+ argv: string[],
1442
+ onDescendant?: (pid: number) => void,
1443
+ ): Promise<CommandResult> {
1444
+ return runCommand(argv, COMMAND_TIMEOUT_MS, onDescendant);
1445
+ }
1446
+
1447
+ export function abortActiveCommunityAppCommandsForTest(): void {
1448
+ for (const controller of activeCommandControllers) controller.abort();
1449
+ }