@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
@@ -5,13 +5,16 @@
5
5
  * binaries. Package-manager installs are migrated to a user binary path
6
6
  * rather than overwritten. Source checkouts and dev-links are never replaced.
7
7
  */
8
+
9
+ import { randomUUID } from "node:crypto";
8
10
  import * as fs from "node:fs";
9
11
  import * as os from "node:os";
10
12
  import * as path from "node:path";
11
- import { pipeline } from "node:stream/promises";
13
+ import { exactReplaceRetained, exactUnlink } from "@gajae-code/natives";
12
14
  import { $which, APP_NAME, isCompiledBinary, isEnoent, redactCrashSecrets, VERSION } from "@gajae-code/utils";
13
15
  import { $ } from "bun";
14
16
  import chalk from "chalk";
17
+ import { acquireFileLock } from "../config/file-lock";
15
18
  import { Settings } from "../config/settings";
16
19
  import { isUpdateChannel, UPDATE_CHANNELS, type UpdateChannel } from "../config/update-channel";
17
20
  import { installDefaultGjcDefinitions } from "../defaults/gjc-defaults";
@@ -29,6 +32,22 @@ import {
29
32
  verifyDownloadedBinaryChecksum,
30
33
  versionFromTag,
31
34
  } from "./github-release";
35
+ import {
36
+ type ActivationRecordV1,
37
+ buildActivationRecord,
38
+ createActivationRecordFile,
39
+ type DirectoryIdentity,
40
+ type FileIdentity,
41
+ readActivationRecord,
42
+ reconcileActivationRecord,
43
+ sameDirectoryIdentity,
44
+ sameFileIdentity,
45
+ snapshotDirectory,
46
+ snapshotRegularFile,
47
+ toNativeIdentity,
48
+ updateActivationRecordFile,
49
+ } from "./install-activation";
50
+ import { COMMUNITY_APP_REPOSITORY, offerMacosCommunityApp } from "./macos-community-app";
32
51
  import { runNotifyCommand } from "./notify-cli";
33
52
 
34
53
  const PACKAGE = "@gajae-code/coding-agent";
@@ -82,6 +101,10 @@ export interface BinaryReplacementOptions {
82
101
  tempPath: string;
83
102
  backupPath: string;
84
103
  expectedVersion: string;
104
+ originalTarget: FileIdentity | undefined;
105
+ originalParent: DirectoryIdentity;
106
+ candidate?: { readonly channel: string; readonly ref: string; readonly sha256: string };
107
+ verifyStagedVersion?: (stagingPath: string, expectedVersion: string) => Promise<void>;
85
108
  verifyInstalledVersion: (expectedVersion: string) => Promise<InstalledVersionVerification>;
86
109
  }
87
110
 
@@ -299,6 +322,10 @@ export function isProtectedSourcePathForTest(filePath: string): boolean {
299
322
  return isProtectedSourcePath(filePath);
300
323
  }
301
324
 
325
+ export function isProtectedSourcePathForInstall(filePath: string): boolean {
326
+ return isProtectedSourcePath(filePath);
327
+ }
328
+
302
329
  export function defaultUserBinaryPathForTest(
303
330
  platform: NodeJS.Platform = process.platform,
304
331
  env: NodeJS.ProcessEnv = process.env,
@@ -387,7 +414,10 @@ export function compareVersionsForTest(a: string, b: string): number {
387
414
  /**
388
415
  * Get the appropriate binary name for this platform.
389
416
  */
390
- function getBinaryName(platform: NodeJS.Platform = process.platform, arch: string = process.arch): string {
417
+ export function getBinaryNameForPlatform(
418
+ platform: NodeJS.Platform = process.platform,
419
+ arch: string = process.arch,
420
+ ): string {
391
421
  let os: string;
392
422
  switch (platform) {
393
423
  case "linux":
@@ -423,6 +453,7 @@ function getBinaryName(platform: NodeJS.Platform = process.platform, arch: strin
423
453
  }
424
454
  return `${APP_NAME}-${os}-${archName}`;
425
455
  }
456
+ const getBinaryName = getBinaryNameForPlatform;
426
457
 
427
458
  /**
428
459
  * Resolve the running GJC image. Compiled binaries update themselves via
@@ -741,176 +772,169 @@ export function formatVerificationFailureForTest(
741
772
  return formatVerificationFailure(result, expectedVersion);
742
773
  }
743
774
 
744
- async function unlinkIfExists(filePath: string): Promise<void> {
745
- try {
746
- await fs.promises.unlink(filePath);
747
- } catch (err) {
748
- if (!isEnoent(err)) throw err;
749
- }
750
- }
751
-
752
- function formatBackupCleanupWarning(backupPath: string, err: unknown): string {
753
- return `Installed update, but could not remove backup file ${backupPath}: ${err}. You can delete it manually after closing shells or antivirus processes that may still hold it.`;
754
- }
755
-
756
- async function cleanupVerifiedBackup(backupPath: string): Promise<string | undefined> {
757
- try {
758
- await unlinkIfExists(backupPath);
759
- return undefined;
760
- } catch (err) {
761
- return formatBackupCleanupWarning(backupPath, err);
762
- }
763
- }
764
-
765
775
  export async function recoverWindowsUpdateJournal(journalPath: string): Promise<void> {
766
- let raw: string;
767
776
  try {
768
- raw = await fs.promises.readFile(journalPath, "utf8");
769
- } catch (err) {
770
- if (isEnoent(err)) return;
771
- throw err;
772
- }
773
- let target = "";
774
- let backup = "";
775
- let next = "";
776
- try {
777
- const parsed: unknown = JSON.parse(raw);
778
- if (parsed && typeof parsed === "object") {
779
- const record = parsed as { target?: unknown; backup?: unknown; next?: unknown };
780
- if (typeof record.target === "string") target = record.target;
781
- if (typeof record.backup === "string") backup = record.backup;
782
- if (typeof record.next === "string") next = record.next;
783
- }
784
- } catch {
785
- await unlinkIfExists(journalPath);
786
- return;
787
- }
788
- const exists = async (p: string): Promise<boolean> => {
789
- try {
790
- await fs.promises.lstat(p);
791
- return true;
792
- } catch (err) {
793
- if (isEnoent(err)) return false;
794
- throw err;
795
- }
796
- };
797
- if (target && next && (await exists(next))) {
798
- if (!(await exists(target))) {
799
- await fs.promises.rename(next, target);
800
- await unlinkIfExists(journalPath);
801
- return;
802
- }
803
- try {
804
- const recoverBackup = `${target}.bak.recover.${process.pid}.${Date.now().toString(16)}`;
805
- await fs.promises.rename(target, recoverBackup);
806
- try {
807
- await fs.promises.rename(next, target);
808
- } catch (promoteErr) {
809
- try {
810
- await fs.promises.rename(recoverBackup, target);
811
- } catch {
812
- throw promoteErr;
813
- }
814
- throw promoteErr;
815
- }
816
- await unlinkIfExists(recoverBackup);
817
- await unlinkIfExists(journalPath);
818
- return;
819
- } catch {
820
- return;
821
- }
822
- }
823
- if (target && backup && !(await exists(target))) {
824
- try {
825
- await fs.promises.rename(backup, target);
826
- } catch (restoreErr) {
827
- if (!isEnoent(restoreErr)) throw restoreErr;
828
- }
777
+ await fs.promises.lstat(journalPath);
778
+ } catch (error) {
779
+ if (isEnoent(error)) return;
780
+ throw error;
829
781
  }
830
- await unlinkIfExists(journalPath);
782
+ throw new Error("legacy_update_journal_requires_manual_review");
831
783
  }
832
784
 
833
785
  /**
834
786
  * Atomically replace the installed binary and roll back if version verification fails.
835
787
  */
836
788
  export async function replaceBinaryForUpdate(options: BinaryReplacementOptions): Promise<InstalledVersionVerification> {
837
- let backupReady = false;
838
- let published = false;
839
- let stagedNext = false;
840
- const journalPath = `${options.targetPath}.update-journal`;
789
+ const targetPath = path.resolve(options.targetPath);
790
+ const parentPath = path.dirname(targetPath);
791
+ if (
792
+ path.dirname(path.resolve(options.tempPath)) !== parentPath ||
793
+ path.dirname(path.resolve(options.backupPath)) !== parentPath
794
+ )
795
+ throw new Error("installation_staging_parent_mismatch");
796
+ await recoverWindowsUpdateJournal(`${targetPath}.update-journal`);
797
+ const parent = await snapshotDirectory(parentPath);
798
+ const current = await snapshotRegularFile(targetPath);
799
+ if (
800
+ !sameDirectoryIdentity(options.originalParent, parent) ||
801
+ (options.originalTarget
802
+ ? !current || !sameFileIdentity(options.originalTarget, current.identity)
803
+ : current !== undefined)
804
+ )
805
+ throw new Error("installation_original_target_changed");
806
+ const existing = await readActivationRecord(targetPath);
807
+ if (existing.status === "malformed" || existing.status === "foreign") throw new Error("activation_record_untrusted");
808
+ const resumePinned =
809
+ existing.status === "valid" &&
810
+ existing.record.phase !== "verified" &&
811
+ existing.record.phase !== "rolled_back" &&
812
+ options.candidate !== undefined;
813
+ const staged = resumePinned ? undefined : await snapshotRegularFile(options.tempPath);
814
+ if (!resumePinned) {
815
+ if (
816
+ !staged ||
817
+ staged.identity.parentDev !== options.originalParent.dev ||
818
+ staged.identity.parentIno !== options.originalParent.ino
819
+ )
820
+ throw new Error("installation_staging_unavailable");
821
+ if (options.candidate && options.candidate.sha256.toLowerCase() !== staged.identity.sha256)
822
+ throw new Error("candidate_digest_mismatch");
823
+ await (options.verifyStagedVersion ?? (async (file, version) => smokeTestPinnedCandidate({ version }, file)))(
824
+ options.tempPath,
825
+ options.expectedVersion,
826
+ );
827
+ const stagedAfter = await snapshotRegularFile(options.tempPath);
828
+ if (!stagedAfter || !sameFileIdentity(staged.identity, stagedAfter.identity))
829
+ throw new Error("installation_candidate_changed");
830
+ }
831
+ let record: ActivationRecordV1;
832
+ let recordIdentity: FileIdentity;
833
+ if (existing.status === "valid" && existing.record.phase !== "verified" && existing.record.phase !== "rolled_back") {
834
+ if (
835
+ existing.record.candidate.digest !== (options.candidate?.sha256.toLowerCase() ?? staged?.identity.sha256) ||
836
+ existing.record.candidate.version !== options.expectedVersion ||
837
+ (options.candidate &&
838
+ (existing.record.candidate.ref !== options.candidate.ref ||
839
+ existing.record.candidate.channel !== options.candidate.channel))
840
+ )
841
+ throw new Error("activation_transaction_requires_reconciliation");
842
+ record = existing.record;
843
+ recordIdentity = existing.identity;
844
+ } else {
845
+ if (!staged) throw new Error("installation_staging_unavailable");
846
+ record = {
847
+ ...buildActivationRecord({
848
+ targetPath,
849
+ targetIdentity: options.originalTarget,
850
+ originalTargetAbsent: options.originalTarget === undefined,
851
+ parentIdentity: options.originalParent,
852
+ baselineDigest: options.originalTarget?.sha256,
853
+ baselineVersion: VERSION,
854
+ stagingPath: path.resolve(options.tempPath),
855
+ stagingIdentity: staged.identity,
856
+ candidate: {
857
+ digest: staged.identity.sha256,
858
+ version: options.expectedVersion,
859
+ ref: options.candidate?.ref ?? `v${options.expectedVersion}`,
860
+ channel: options.candidate?.channel,
861
+ os: process.platform,
862
+ arch: process.arch,
863
+ },
864
+ }),
865
+ backupName: path.basename(options.backupPath),
866
+ };
867
+ recordIdentity =
868
+ existing.status === "valid"
869
+ ? await updateActivationRecordFile(record, existing.identity)
870
+ : await createActivationRecordFile(record);
871
+ }
872
+ const promoted = await reconcileActivationRecord(targetPath, {
873
+ allowPromotion: true,
874
+ expectedRecordIdentity: recordIdentity,
875
+ });
876
+ if (promoted.status === "pending_activation") throw new Error("installation_pending_activation");
877
+ if (
878
+ (promoted.status !== "reconciled" &&
879
+ promoted.status !== "applied_unverified" &&
880
+ promoted.status !== "verified") ||
881
+ !promoted.record ||
882
+ !promoted.recordIdentity
883
+ )
884
+ throw new Error(promoted.reason ?? "installation_publication_unverified");
885
+ record = promoted.record;
886
+ recordIdentity = promoted.recordIdentity;
841
887
  try {
842
- if (process.platform === "win32") {
843
- await recoverWindowsUpdateJournal(journalPath);
844
- }
888
+ const verification = await options.verifyInstalledVersion(options.expectedVersion);
889
+ if (!verification.ok) throw new Error(formatVerificationFailure(verification, options.expectedVersion));
890
+ const after = await snapshotRegularFile(targetPath);
891
+ if (!after || !record.targetIdentity || !sameFileIdentity(record.targetIdentity, after.identity))
892
+ throw new Error("installation_postcheck_identity_changed");
893
+ await updateActivationRecordFile({ ...record, phase: "verified" }, recordIdentity);
894
+ return verification;
895
+ } catch (primary) {
845
896
  try {
846
- const dest = await fs.promises.lstat(options.targetPath);
847
- if (dest.isSymbolicLink()) {
848
- throw new Error(
849
- `Refusing to replace symlink ${options.targetPath} with a regular binary. Set GJC_INSTALL_DIR to a real directory.`,
897
+ const after = await snapshotRegularFile(targetPath);
898
+ if (!after || !record.targetIdentity || !sameFileIdentity(record.targetIdentity, after.identity))
899
+ throw new Error("rollback_conflict_target_changed");
900
+ recordIdentity = await updateActivationRecordFile({ ...record, phase: "uncertain" }, recordIdentity);
901
+ const failedName = `.gjc-update-failed-${record.transactionId}`;
902
+ if (record.baseline.exists) {
903
+ const backupPath = path.join(parentPath, record.backupName);
904
+ const backup = await snapshotRegularFile(backupPath);
905
+ if (!backup || !record.backupIdentity || !sameFileIdentity(record.backupIdentity, backup.identity))
906
+ throw new Error("rollback_backup_changed");
907
+ const restored = exactReplaceRetained(
908
+ backupPath,
909
+ targetPath,
910
+ failedName,
911
+ toNativeIdentity(record.backupIdentity),
912
+ toNativeIdentity(record.targetIdentity),
913
+ );
914
+ const observed = await snapshotRegularFile(targetPath);
915
+ if (!restored.ok || !observed || !sameFileIdentity(record.backupIdentity, observed.identity))
916
+ throw new Error("rollback_unverified");
917
+ await updateActivationRecordFile(
918
+ { ...record, targetIdentity: observed.identity, phase: "rolled_back" },
919
+ recordIdentity,
920
+ );
921
+ } else {
922
+ const removed = exactUnlink(targetPath, {
923
+ ...toNativeIdentity(record.targetIdentity, failedName),
924
+ detachOnly: true,
925
+ });
926
+ const absent = await snapshotRegularFile(targetPath);
927
+ if ((!removed.ok && removed.code !== "cleanup_pending") || absent !== undefined)
928
+ throw new Error("rollback_unverified");
929
+ await updateActivationRecordFile(
930
+ { ...record, targetIdentity: undefined, phase: "rolled_back" },
931
+ recordIdentity,
850
932
  );
851
933
  }
852
- } catch (err) {
853
- if (!isEnoent(err)) throw err;
854
- }
855
- await unlinkIfExists(options.backupPath);
856
- if (process.platform === "win32") {
857
- const nextPath = `${options.targetPath}.next`;
858
- await fs.promises.writeFile(
859
- journalPath,
860
- JSON.stringify({
861
- target: options.targetPath,
862
- backup: options.backupPath,
863
- temp: options.tempPath,
864
- next: nextPath,
865
- }),
866
- "utf8",
867
- );
868
- try {
869
- await fs.promises.rename(options.targetPath, options.backupPath);
870
- backupReady = true;
871
- } catch (err) {
872
- if (isEnoent(err)) {
873
- backupReady = false;
874
- } else {
875
- await fs.promises.copyFile(options.tempPath, nextPath);
876
- stagedNext = true;
877
- throw new Error(
878
- `Running Windows image ${options.targetPath} could not be replaced in-process (${err}). Staged ${nextPath}. Close running gjc.exe and re-run gjc update.`,
879
- );
880
- }
881
- }
882
- } else {
883
- try {
884
- await fs.promises.copyFile(options.targetPath, options.backupPath);
885
- backupReady = true;
886
- } catch (err) {
887
- if (!isEnoent(err)) throw err;
888
- }
889
- }
890
- await fs.promises.rename(options.tempPath, options.targetPath);
891
- published = true;
892
-
893
- const verification = await options.verifyInstalledVersion(options.expectedVersion);
894
- if (!verification.ok) {
895
- throw new Error(
896
- `${formatVerificationFailure(verification, options.expectedVersion)}; restored previous ${APP_NAME} binary`,
897
- );
898
- }
899
-
900
- backupReady = false;
901
- if (process.platform === "win32") await unlinkIfExists(journalPath);
902
- const cleanupWarning = await cleanupVerifiedBackup(options.backupPath);
903
- return cleanupWarning ? { ...verification, cleanupWarning } : verification;
904
- } catch (err) {
905
- if (backupReady) {
906
- await unlinkIfExists(options.targetPath);
907
- await fs.promises.rename(options.backupPath, options.targetPath);
908
- } else if (published) {
909
- await unlinkIfExists(options.targetPath);
934
+ } catch (rollback) {
935
+ throw new AggregateError([primary, rollback], "installation_rollback_unverified");
910
936
  }
911
- await unlinkIfExists(options.tempPath);
912
- if (process.platform === "win32" && !stagedNext) await unlinkIfExists(journalPath);
913
- throw err;
937
+ throw primary;
914
938
  }
915
939
  }
916
940
 
@@ -1004,7 +1028,7 @@ async function downloadBinaryTo(
1004
1028
  expectedVersion?: string,
1005
1029
  ): Promise<void> {
1006
1030
  const response = await fetch(url, { redirect: "follow" });
1007
- if (!response.ok || !response.body) {
1031
+ if (!response.ok || !response.body)
1008
1032
  throw new Error(
1009
1033
  formatBinaryDownloadFailureMessage(
1010
1034
  binaryName,
@@ -1014,41 +1038,93 @@ async function downloadBinaryTo(
1014
1038
  registryNote,
1015
1039
  ),
1016
1040
  );
1041
+ const limit = 512 * 1024 * 1024;
1042
+ const declared = response.headers.get("content-length");
1043
+ if (declared && /^\d+$/.test(declared) && Number(declared) > limit) {
1044
+ await response.body.cancel();
1045
+ throw new Error("candidate_size_limit");
1017
1046
  }
1047
+ const handle = await fs.promises.open(
1048
+ tempPath,
1049
+ fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW,
1050
+ 0o600,
1051
+ );
1052
+ const reader = response.body.getReader();
1018
1053
  try {
1019
- const fileStream = fs.createWriteStream(tempPath, { mode: 0o755 });
1020
- await pipeline(response.body, fileStream);
1021
- const stat = await fs.promises.stat(tempPath);
1022
- if (stat.size <= 0) {
1023
- throw new Error(`Downloaded file was empty: ${url}`);
1054
+ let size = 0;
1055
+ for (;;) {
1056
+ const next = await reader.read();
1057
+ if (next.done) break;
1058
+ size += next.value.byteLength;
1059
+ if (size > limit) {
1060
+ await reader.cancel();
1061
+ throw new Error("candidate_size_limit");
1062
+ }
1063
+ await handle.writeFile(next.value);
1024
1064
  }
1025
- } catch (err) {
1026
- await unlinkIfExists(tempPath);
1027
- throw err;
1028
- }
1029
- if (expectedVersion) {
1030
- const tag = expectedVersion.startsWith("v") ? expectedVersion : `v${expectedVersion}`;
1031
- try {
1065
+ if (size === 0) throw new Error("candidate_empty");
1066
+ await handle.sync();
1067
+ const beforeVerification = await handle.stat({ bigint: true });
1068
+ if (expectedVersion)
1032
1069
  await verifyDownloadedBinaryChecksum({
1033
- tag,
1070
+ tag: expectedVersion.startsWith("v") ? expectedVersion : `v${expectedVersion}`,
1034
1071
  assetName: binaryName,
1035
1072
  filePath: tempPath,
1036
1073
  });
1037
- } catch (err) {
1038
- await unlinkIfExists(tempPath);
1039
- throw err;
1040
- }
1074
+ const current = await fs.promises.lstat(tempPath, { bigint: true });
1075
+ if (
1076
+ !current.isFile() ||
1077
+ current.isSymbolicLink() ||
1078
+ current.dev !== beforeVerification.dev ||
1079
+ current.ino !== beforeVerification.ino ||
1080
+ current.size !== beforeVerification.size ||
1081
+ current.mtimeNs !== beforeVerification.mtimeNs
1082
+ )
1083
+ throw new Error("candidate_changed_during_verification");
1084
+ await handle.chmod(0o755);
1085
+ await handle.sync();
1086
+ } finally {
1087
+ reader.releaseLock();
1088
+ await handle.close();
1089
+ }
1090
+ }
1091
+
1092
+ /** Production adapter used by doctor restore: official release origin, checksum manifest, and runtime smoke. */
1093
+ export async function fetchAndVerifyOfficialPinnedCandidate(
1094
+ candidate: { ref: string; version: string; os: NodeJS.Platform; arch: string; sha256: string },
1095
+ stagingPath: string,
1096
+ ): Promise<void> {
1097
+ if (candidate.os !== process.platform || candidate.arch !== process.arch)
1098
+ throw new Error("candidate_platform_or_arch_mismatch");
1099
+ const expectedTag = candidate.ref.startsWith("v") ? candidate.ref : `v${candidate.ref}`;
1100
+ const expectedVersion = versionFromTag(expectedTag);
1101
+ if (expectedVersion !== candidate.version) throw new Error("candidate_ref_version_mismatch");
1102
+ await downloadBinaryTo(
1103
+ buildReleaseBinaryUrl(candidate.version, candidate.os, candidate.arch),
1104
+ stagingPath,
1105
+ getBinaryNameForPlatform(candidate.os, candidate.arch),
1106
+ undefined,
1107
+ candidate.version,
1108
+ );
1109
+ const staged = await snapshotRegularFile(stagingPath);
1110
+ if (!staged) throw new Error("candidate_missing");
1111
+ const digest = staged.identity.sha256;
1112
+ if (digest.toLowerCase() !== candidate.sha256.toLowerCase()) {
1113
+ throw new Error("candidate_digest_mismatch");
1041
1114
  }
1042
1115
  }
1043
1116
 
1117
+ export async function smokeTestPinnedCandidate(candidate: { version: string }, stagingPath: string): Promise<void> {
1118
+ const verification = await verifyInstalledRuntime(candidate.version, stagingPath);
1119
+ if (!verification.ok) throw new Error(formatVerificationFailure(verification, candidate.version));
1120
+ }
1121
+
1044
1122
  /** Injectable steps of the binary update flow (seams for testing ordering). */
1045
1123
  export interface BinaryUpdateFlow {
1046
1124
  download(url: string, tempPath: string): Promise<void>;
1047
1125
  fsync(filePath: string): Promise<void>;
1048
1126
  replace(options: BinaryReplacementOptions): Promise<InstalledVersionVerification>;
1049
1127
  verifyInstalledVersion(expectedVersion: string): Promise<InstalledVersionVerification>;
1050
- /** Best-effort cleanup of the temp file when the flow aborts before replace. */
1051
- removeTemp?(filePath: string): Promise<void>;
1052
1128
  /** Called once fsync has succeeded, right before replacement begins. */
1053
1129
  beforeReplace?(): void;
1054
1130
  }
@@ -1059,8 +1135,8 @@ export interface BinaryUpdateFlow {
1059
1135
  * before it is published (renamed into place) or exec'd for verification.
1060
1136
  *
1061
1137
  * If fsync fails the temp bytes are not durable, so we abort before
1062
- * replacement/verification and clean up the temp file rather than installing a
1063
- * possibly-truncated binary.
1138
+ * replacement/verification and retain the staging artifact rather than risk
1139
+ * deleting a substituted pathname or installing a possibly-truncated binary.
1064
1140
  */
1065
1141
  export async function runBinaryUpdateFlow(
1066
1142
  targetPath: string,
@@ -1068,18 +1144,16 @@ export async function runBinaryUpdateFlow(
1068
1144
  expectedVersion: string,
1069
1145
  flow: BinaryUpdateFlow,
1070
1146
  ): Promise<InstalledVersionVerification> {
1071
- const stamp = `${process.pid}.${Date.now().toString(16)}`;
1147
+ const stamp = randomUUID();
1072
1148
  const tempPath = `${targetPath}.new.${stamp}`;
1073
1149
  const backupPath = `${targetPath}.bak.${stamp}`;
1074
1150
  const releaseLock = await acquireBinaryUpdateLock(targetPath);
1075
1151
  try {
1152
+ const originalTarget = await snapshotRegularFile(targetPath);
1153
+ const originalParent = await snapshotDirectory(path.dirname(targetPath));
1154
+ if (!originalParent) throw new Error("installation_parent_unavailable");
1076
1155
  await flow.download(url, tempPath);
1077
- try {
1078
- await flow.fsync(tempPath);
1079
- } catch (err) {
1080
- if (flow.removeTemp) await flow.removeTemp(tempPath);
1081
- throw err;
1082
- }
1156
+ await flow.fsync(tempPath);
1083
1157
 
1084
1158
  flow.beforeReplace?.();
1085
1159
  return await flow.replace({
@@ -1087,6 +1161,8 @@ export async function runBinaryUpdateFlow(
1087
1161
  tempPath,
1088
1162
  backupPath,
1089
1163
  expectedVersion,
1164
+ originalTarget: originalTarget?.identity,
1165
+ originalParent,
1090
1166
  verifyInstalledVersion: flow.verifyInstalledVersion,
1091
1167
  });
1092
1168
  } finally {
@@ -1094,46 +1170,14 @@ export async function runBinaryUpdateFlow(
1094
1170
  }
1095
1171
  }
1096
1172
 
1097
- async function acquireBinaryUpdateLock(targetPath: string): Promise<() => Promise<void>> {
1098
- const lockFile = path.join(path.dirname(targetPath), ".gjc-install.lock");
1099
- const nonce = `${process.pid}.${Date.now().toString(16)}.${Math.random().toString(16).slice(2)}`;
1100
- const claim = `${process.pid} ${nonce}\n`;
1101
- const publish = async (): Promise<void> => {
1102
- const handle = await fs.promises.open(lockFile, "wx");
1103
- try {
1104
- await handle.write(claim);
1105
- } finally {
1106
- await handle.close();
1107
- }
1108
- };
1109
- const ownsClaim = async (): Promise<boolean> => {
1110
- try {
1111
- return (await fs.promises.readFile(lockFile, "utf8")) === claim;
1112
- } catch {
1113
- return false;
1114
- }
1115
- };
1116
- const release = async (): Promise<void> => {
1117
- try {
1118
- if (!(await ownsClaim())) return;
1119
- await unlinkIfExists(lockFile);
1120
- } catch {
1121
- // Best-effort lock release after a verified or failed update.
1122
- }
1123
- };
1124
- try {
1125
- await publish();
1126
- return release;
1127
- } catch (err) {
1128
- const code = (err as NodeJS.ErrnoException).code;
1129
- if (code === "ENOENT") return async () => {};
1130
- if (code === "EEXIST") {
1131
- throw new Error(
1132
- `Another ${APP_NAME} update is already running for ${targetPath}. Remove ${lockFile} only after confirming no installer or update is running.`,
1133
- );
1134
- }
1135
- throw err;
1136
- }
1173
+ export async function acquireBinaryUpdateLock(targetPath: string): Promise<() => Promise<void>> {
1174
+ const parent = await fs.promises.lstat(path.dirname(path.resolve(targetPath))).catch(() => undefined);
1175
+ if (!parent?.isDirectory()) throw new Error("installation_directory_unavailable");
1176
+ return await acquireFileLock(path.join(path.dirname(path.resolve(targetPath)), ".gjc-install"), {
1177
+ signal: AbortSignal.timeout(10_000),
1178
+ retries: 200,
1179
+ retryDelayMs: 50,
1180
+ });
1137
1181
  }
1138
1182
 
1139
1183
  /**
@@ -1233,13 +1277,10 @@ async function updateViaBinaryAt(
1233
1277
  fsync: fsyncFile,
1234
1278
  replace: replaceBinaryForUpdate,
1235
1279
  verifyInstalledVersion: version => verifyInstalledRuntime(version, targetPath),
1236
- removeTemp: unlinkIfExists,
1237
1280
  beforeReplace: () => console.log(chalk.dim("Installing update...")),
1238
1281
  });
1239
1282
 
1240
- printVerifiedVersion(expectedVersion);
1241
1283
  if (verification.cleanupWarning) console.warn(chalk.yellow(verification.cleanupWarning));
1242
- printRestartGuidance();
1243
1284
  return verification;
1244
1285
  }
1245
1286
 
@@ -1247,6 +1288,8 @@ async function updateViaBinaryAt(
1247
1288
  * Run the update command.
1248
1289
  */
1249
1290
  export interface UpdateCommandDependencies {
1291
+ /** User-visible migration guidance owned by the standalone update command, not the TUI logger. */
1292
+ writeStdout?: (text: string) => void;
1250
1293
  getLatestRelease?: (options?: LatestReleaseLookupOptions) => Promise<ReleaseInfo>;
1251
1294
  resolveUpdateTarget?: () => Promise<UpdateTarget>;
1252
1295
  verifyMigrationTarget?: (release: ReleaseInfo, runtimePath: string) => Promise<InstalledVersionVerification>;
@@ -1261,6 +1304,8 @@ export interface UpdateCommandDependencies {
1261
1304
  restartDaemon?: (settings: Settings) => Promise<void>;
1262
1305
  recoverNotifications?: (settings: Settings) => Promise<void>;
1263
1306
  runPostUpdateRecovery?: (runtimePath: string) => Promise<void>;
1307
+ platform?: NodeJS.Platform;
1308
+ offerMacosCommunityApp?: typeof offerMacosCommunityApp;
1264
1309
  recordTelemetryEvent?: (event: TelemetryEventName, details: TelemetryDetails) => unknown;
1265
1310
  exit?: (code: number) => never;
1266
1311
  }
@@ -1269,6 +1314,24 @@ export type PostUpdateRecoverySpawn = (argv: string[]) => Promise<number>;
1269
1314
  export type PostUpdateRecoverySupportCheck = (runtimePath: string) => Promise<boolean>;
1270
1315
  export type LegacyRecoveryDaemonKinds = () => Promise<NotificationProvider[]>;
1271
1316
 
1317
+ async function offerCommunityAppAfterUpdate(deps: UpdateCommandDependencies): Promise<void> {
1318
+ const platform = deps.platform ?? process.platform;
1319
+ if (platform !== "darwin") return;
1320
+ try {
1321
+ await (deps.offerMacosCommunityApp ?? offerMacosCommunityApp)({
1322
+ platform,
1323
+ log: message => {
1324
+ process.stderr.write(`${message}\n`);
1325
+ },
1326
+ });
1327
+ } catch (error) {
1328
+ const reason = sanitizeVerificationOutput(error instanceof Error ? error.message : String(error));
1329
+ process.stderr.write(
1330
+ `Optional community app offer failed: ${reason ?? "unknown error"}. GJC remains installed. https://github.com/${COMMUNITY_APP_REPOSITORY}\n`,
1331
+ );
1332
+ }
1333
+ }
1334
+
1272
1335
  /**
1273
1336
  * A complete, non-quarantined provider with provider-level desired intent is a
1274
1337
  * durable managed-notify setup. The global switch is deliberately excluded:
@@ -1394,17 +1457,11 @@ async function performUpdate(
1394
1457
  if (target.previousPath) {
1395
1458
  console.log(
1396
1459
  chalk.yellow(
1397
- `Current ${APP_NAME} at ${target.previousPath} is a package-manager shim or wrapper; installing a standalone binary at ${target.path} without overwriting the shim.`,
1460
+ `Current ${APP_NAME} at ${sanitizeVerificationOutput(target.previousPath)} is a package-manager shim or wrapper; installing a standalone binary at ${sanitizeVerificationOutput(target.path)} without overwriting the shim.`,
1398
1461
  ),
1399
1462
  );
1400
1463
  }
1401
- const verification = await updateViaBinaryAt(target.path, expectedVersion, registry);
1402
- console.log(
1403
- chalk.cyan(
1404
- `Put ${path.dirname(target.path)} first on PATH so the standalone binary wins over any leftover Bun/npm shim.`,
1405
- ),
1406
- );
1407
- return verification;
1464
+ return await updateViaBinaryAt(target.path, expectedVersion, registry);
1408
1465
  }
1409
1466
  if (target.method === "binary") {
1410
1467
  return await updateViaBinaryAt(target.path, expectedVersion, registry);
@@ -1454,22 +1511,43 @@ export function resolveUpdateDecision(options: {
1454
1511
  return { install: true, kind: options.comparison > 0 ? "new-version" : "force" };
1455
1512
  }
1456
1513
 
1457
- function printVerifiedMigrationTarget(target: MigrationUpdateTarget, version: string): void {
1458
- console.log(
1459
- chalk.green(
1460
- `${theme.status.success} Standalone ${APP_NAME} ${version} is already installed and verified at ${target.path}`,
1461
- ),
1514
+ export function formatVerifiedBinaryInvocation(runtimePath: string, platform: NodeJS.Platform): string {
1515
+ // PowerShell treats smart single quotes as delimiters too; preserve each by doubling it.
1516
+ return platform === "win32"
1517
+ ? `& '${runtimePath.replace(/['‘’‚‛]/g, quote => quote + quote)}'`
1518
+ : `'${runtimePath.replace(/'/g, "'\\''")}'`;
1519
+ }
1520
+
1521
+ function printVerifiedMigrationTarget(
1522
+ target: MigrationUpdateTarget,
1523
+ version: string,
1524
+ writeStdout: (text: string) => void,
1525
+ alreadyInstalled = true,
1526
+ ): void {
1527
+ const displayPath = sanitizeVerificationOutput(target.path);
1528
+ const directory = sanitizeVerificationOutput(path.dirname(target.path));
1529
+ const quotedPath = formatVerifiedBinaryInvocation(target.path, process.platform);
1530
+ const invocation =
1531
+ displayPath === target.path
1532
+ ? `Run the verified binary directly: ${quotedPath} --version (omit --version to launch).`
1533
+ : "Run the verified binary directly using its exact local path (displayed path was sanitized).";
1534
+ writeStdout(
1535
+ `${chalk.green(
1536
+ `${theme.status.success} Standalone ${APP_NAME} ${version} is ${alreadyInstalled ? "already installed" : "installed"} and verified at ${displayPath}.${version === VERSION ? " Version unchanged; this is an installation migration, not a version update." : ""}`,
1537
+ )}\n`,
1538
+ );
1539
+ writeStdout(
1540
+ `${chalk.cyan(
1541
+ [
1542
+ "Shell activation is not verified; package-manager shims were not overwritten or uninstalled.",
1543
+ invocation,
1544
+ process.platform === "win32"
1545
+ ? "Check resolution in PowerShell: Get-Command gjc -All; where.exe gjc."
1546
+ : "In your current shell, check: type -a gjc; command -v gjc. Clear cached commands with hash -r (Bash) or rehash (zsh), then repeat the checks and run gjc --version.",
1547
+ `Only if resolution still selects another install, ensure ${directory} is on PATH before the shim directory; also check shell aliases/functions.`,
1548
+ ].join("\n"),
1549
+ )}\n`,
1462
1550
  );
1463
- if (target.previousPath) {
1464
- console.log(chalk.yellow(`${target.previousPath} shadows it on PATH.`));
1465
- console.log(
1466
- chalk.cyan(
1467
- `The standalone directory ${path.dirname(target.path)} must precede the shim directory ${path.dirname(target.previousPath)} on PATH.`,
1468
- ),
1469
- );
1470
- return;
1471
- }
1472
- console.log(chalk.cyan(`Ensure the standalone directory ${path.dirname(target.path)} is on PATH.`));
1473
1551
  }
1474
1552
 
1475
1553
  export async function runUpdateCommand(
@@ -1483,6 +1561,7 @@ export async function runUpdateCommand(
1483
1561
  const update = deps.performUpdate ?? performUpdate;
1484
1562
  const refreshDefaults = deps.refreshInstalledDefaultSkills ?? refreshInstalledDefaultSkills;
1485
1563
  const exit = deps.exit ?? process.exit;
1564
+ const writeStdout = deps.writeStdout ?? (text => process.stdout.write(text));
1486
1565
  const recordEvent = deps.recordTelemetryEvent ?? ((event, details) => recordTelemetryEvent(event, details));
1487
1566
  const pendingTelemetry = new Set<Promise<void>>();
1488
1567
  const record = (event: TelemetryEventName, details: TelemetryDetails): void => {
@@ -1553,18 +1632,31 @@ export async function runUpdateCommand(
1553
1632
  });
1554
1633
 
1555
1634
  if (target.method === "migrate" && decision.install && !opts.force) {
1556
- const releaseLock = await acquireBinaryUpdateLock(target.path);
1635
+ // Check mode is read-only, including when another installer holds the lock.
1636
+ // A first-time migration may intentionally target a path whose parent does
1637
+ // not exist yet; the real install creates it before entering the locked
1638
+ // replacement flow. There is no existing lock to contend with in that
1639
+ // state, so preflight remains read-only and lock-free until installation.
1640
+ const targetParent = path.dirname(path.resolve(target.path));
1641
+ const parent = await fs.promises.lstat(targetParent).catch(() => undefined);
1642
+ const releaseLock = opts.check || !parent?.isDirectory() ? undefined : await acquireBinaryUpdateLock(target.path);
1643
+ let verified = false;
1557
1644
  try {
1558
- const verification = await verifyTarget(release, target.path);
1559
- if (verification.ok) {
1560
- record("update_check_completed", { channel, result: "available" });
1561
- record("update_install_started", { channel, installMethod: target.method });
1562
- printVerifiedMigrationTarget(target, release.version);
1563
- record("update_install_completed", { channel, result: "installed", installMethod: target.method });
1645
+ verified = (await verifyTarget(release, target.path)).ok;
1646
+ } finally {
1647
+ await releaseLock?.();
1648
+ }
1649
+ if (verified) {
1650
+ record("update_check_completed", { channel, result: "available" });
1651
+ printVerifiedMigrationTarget(target, release.version, writeStdout);
1652
+ if (opts.check) {
1653
+ record("update_install_completed", { channel, result: "skipped" });
1564
1654
  return;
1565
1655
  }
1566
- } finally {
1567
- await releaseLock();
1656
+ record("update_install_started", { channel, installMethod: target.method });
1657
+ record("update_install_completed", { channel, result: "installed", installMethod: target.method });
1658
+ await offerCommunityAppAfterUpdate(deps);
1659
+ return;
1568
1660
  }
1569
1661
  }
1570
1662
 
@@ -1595,14 +1687,19 @@ export async function runUpdateCommand(
1595
1687
  try {
1596
1688
  const resolved = target ?? (await resolveTarget());
1597
1689
  const verification = await update(resolved, release.version, release.registry);
1598
- if (verification?.path) {
1690
+ if (verification?.ok && verification.path) {
1599
1691
  installedVersion = release.version;
1692
+ if (resolved.method === "migrate") {
1693
+ printVerifiedMigrationTarget({ ...resolved, path: verification.path }, release.version, writeStdout, false);
1694
+ } else {
1695
+ printSuccessfulVerification(release.version);
1696
+ }
1600
1697
  await (deps.runPostUpdateRecovery ?? runPostUpdateRecovery)(verification.path);
1601
1698
  } else if (!deps.performUpdate) throw new Error("verified installed runtime path is unavailable");
1602
1699
  } catch (err) {
1603
1700
  record("update_install_failed", { channel, result: "failed", installMethod: target.method });
1604
1701
  const prefix = installedVersion
1605
- ? `Updated to ${installedVersion}, but post-update recovery failed`
1702
+ ? `${target.method === "migrate" ? "Standalone binary installed and verified at version" : "Updated to"} ${installedVersion}, but post-update recovery failed`
1606
1703
  : "Update failed";
1607
1704
  console.error(chalk.red(`${prefix}: ${err}`));
1608
1705
  return flushTelemetryBeforeExit();
@@ -1612,6 +1709,7 @@ export async function runUpdateCommand(
1612
1709
  // refreshes opt-in local definitions, avoiding stale-module daemon control.
1613
1710
  await refreshDefaults();
1614
1711
  record("update_install_completed", { channel, result: "installed", installMethod: target.method });
1712
+ await offerCommunityAppAfterUpdate(deps);
1615
1713
  }
1616
1714
 
1617
1715
  /**