oysterun 1.6.0-beta.2 → 1.6.0-beta.21

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 (242) hide show
  1. package/bin/oysterun.mjs +64 -17
  2. package/dev/client/connectivity/completed-message-neighbor-click.mjs +78 -0
  3. package/dev/client/connectivity/completed-message-neighbor-contract.mjs +205 -0
  4. package/dev/client/connectivity/completed-message-visibility.d.mts +4 -4
  5. package/dev/client/connectivity/completed-message-visibility.mjs +14 -11
  6. package/dev/client/connectivity/desktop-chat-sidebar-preference.mjs +57 -0
  7. package/dev/client/connectivity/host-connection-feedback.mjs +12 -1
  8. package/dev/client/connectivity/managed-host-browser.mjs +10 -0
  9. package/dev/client/connectivity/session-navigation-intent.mjs +189 -0
  10. package/dev/client/connectivity/session-transition-state.mjs +18 -6
  11. package/dev/client/connectivity/test-completed-message-neighbor-click.mjs +180 -0
  12. package/dev/client/web/index.html +5367 -609
  13. package/dev/client/web-chat/dist/assets/{ReactPrism-BwJhidYf.js → ReactPrism-C6YiTZTC.js} +1 -1
  14. package/dev/client/web-chat/dist/assets/{ReactPrism-BwJhidYf.js.map → ReactPrism-C6YiTZTC.js.map} +1 -1
  15. package/dev/client/web-chat/dist/assets/SessionsSetupApp-CKJOSPFP.js +6442 -0
  16. package/dev/client/web-chat/dist/assets/SessionsSetupApp-CKJOSPFP.js.map +1 -0
  17. package/dev/client/web-chat/dist/assets/SessionsSetupApp-Dx09Uz-6.css +1 -0
  18. package/dev/client/web-chat/dist/assets/index-Ae5I4LHp.js +9 -0
  19. package/dev/client/web-chat/dist/assets/index-Ae5I4LHp.js.map +1 -0
  20. package/dev/client/web-chat/dist/assets/index-CN_FoTB1.js +2 -0
  21. package/dev/client/web-chat/dist/assets/index-CN_FoTB1.js.map +1 -0
  22. package/dev/client/web-chat/dist/assets/{index--1xmQ99x.js → index-DCQFwgGf.js} +2 -2
  23. package/dev/client/web-chat/dist/assets/{index--1xmQ99x.js.map → index-DCQFwgGf.js.map} +1 -1
  24. package/dev/client/web-chat/dist/assets/matrix-entry-BV7G38kH.css +10 -0
  25. package/dev/client/web-chat/dist/assets/matrix-entry-abAy3aOo.js +594 -0
  26. package/dev/client/web-chat/dist/assets/matrix-entry-abAy3aOo.js.map +1 -0
  27. package/dev/client/web-chat/dist/assets/{pdf-ZjtalfgI.js → pdf-DN7oVvM6.js} +5 -5
  28. package/dev/client/web-chat/dist/assets/pdf-DN7oVvM6.js.map +1 -0
  29. package/dev/client/web-chat/dist/assets/session-notice-DlyODy1z.css +1 -0
  30. package/dev/client/web-chat/dist/assets/web-BlYuDlOV.js +1 -0
  31. package/dev/client/web-chat/dist/assets/{web-c70YaskK.js.map → web-BlYuDlOV.js.map} +1 -1
  32. package/dev/client/web-chat/dist/assets/web-Dabw6cJi.js +1 -0
  33. package/dev/client/web-chat/dist/assets/{web-BzHcZK2M.js.map → web-Dabw6cJi.js.map} +1 -1
  34. package/dev/client/web-chat/dist/index.html +1 -2
  35. package/docs/routec/dev/cloud_deplyments.md +5 -0
  36. package/docs/routec/dev/npm_native_dependency_install.md +6 -2
  37. package/docs/routec/dev/npmjs_publish.md +55 -19
  38. package/docs/routec/dev/p262_setup_input_file.md +117 -0
  39. package/docs/routec/host2_turn_started_idempotency_diagnostics.md +8 -7
  40. package/docs/routec/host_setup.md +39 -10
  41. package/docs/routec/oysterun_operation_interface_spec.md +78 -2
  42. package/docs/routec/provider_contract.md +35 -5
  43. package/host-service/README.md +36 -0
  44. package/host-service/adapters/claude-code-adapter.mjs +48 -5
  45. package/host-service/adapters/codex-app-server-adapter.mjs +320 -18
  46. package/host-service/adapters/debug-fixture-provider-adapter.mjs +102 -1
  47. package/host-service/adapters/debug-large-tool-spillover-provider-adapter.mjs +2 -0
  48. package/host-service/adapters/debug-p135-codex-replay-provider-adapter.mjs +2 -0
  49. package/host-service/adapters/debug-routec-structural-replay-provider-adapter.mjs +2 -0
  50. package/host-service/agent-config.mjs +16 -5
  51. package/host-service/agent-folder-availability.mjs +41 -0
  52. package/host-service/apns-complete-message-dispatcher.mjs +1 -0
  53. package/host-service/assets/product-skills/Oysterun/modules/oysterun-find-context/SKILL.md +24 -0
  54. package/host-service/assets/product-skills/Oysterun/modules/oysterun-session-chat/SKILL.md +94 -5
  55. package/host-service/assets/product-skills/Oysterun/modules/oysterun-sessions/SKILL.md +103 -0
  56. package/host-service/assets/product-skills/Oysterun/modules/oysterun-website/SKILL.md +7 -0
  57. package/host-service/assets/product-skills/oysterun-find-context/SKILL.md +24 -0
  58. package/host-service/assets/product-skills/oysterun-session-chat/SKILL.md +94 -5
  59. package/host-service/assets/product-skills/oysterun-sessions/SKILL.md +103 -0
  60. package/host-service/assets/product-skills/oysterun-website/SKILL.md +7 -0
  61. package/host-service/callback-durable-store.mjs +354 -47
  62. package/host-service/callback-foundation.mjs +21 -1
  63. package/host-service/callback-host-integration.mjs +345 -30
  64. package/host-service/claude-acp-model-discovery.mjs +23 -18
  65. package/host-service/cli/assert-installer-reinstall-target.mjs +9 -0
  66. package/host-service/cli/installer-recovery-guidance.mjs +20 -0
  67. package/host-service/cli/prepare-restart-restore.mjs +2 -0
  68. package/host-service/cli/product-cli.mjs +34 -33
  69. package/host-service/cli/reconcile-initial-preflight.mjs +19 -0
  70. package/host-service/cli/recover-installer-lifecycle.mjs +24 -0
  71. package/host-service/cli/run-host-lifecycle.mjs +2 -0
  72. package/host-service/cli/run-staged-lifecycle-recovery.mjs +161 -0
  73. package/host-service/codex-model-discovery.mjs +23 -18
  74. package/host-service/complete-cli-uninstall.mjs +176 -0
  75. package/host-service/complete-message-notification-predicate.mjs +8 -6
  76. package/host-service/complete-message-notification-title.mjs +4 -0
  77. package/host-service/completed-session-unread.mjs +425 -42
  78. package/host-service/completed-turn-navigation.mjs +444 -144
  79. package/host-service/completed-turn-notification-observer.mjs +116 -0
  80. package/host-service/completed-turn-service.mjs +445 -0
  81. package/host-service/config.mjs +115 -9
  82. package/host-service/desired-open-session-state.mjs +331 -0
  83. package/host-service/fixtures/p218-completed-turn-notification.json +11 -0
  84. package/host-service/frp-agent.mjs +9 -9
  85. package/host-service/grok-acp-model-discovery.mjs +31 -33
  86. package/host-service/host-lifecycle-recovery.mjs +37 -0
  87. package/host-service/host-port-resolution.mjs +98 -0
  88. package/host-service/host-runtime-diagnostics.mjs +18 -1
  89. package/host-service/initial-preflight-recovery.mjs +71 -0
  90. package/host-service/installer-recovery-guidance.mjs +85 -0
  91. package/host-service/installer-reinstall-bindings.mjs +49 -0
  92. package/host-service/installer-reinstall-target.mjs +49 -0
  93. package/host-service/legacy-continuity-observation.mjs +33 -0
  94. package/host-service/lifecycle-compat-cli.mjs +13 -0
  95. package/host-service/lifecycle-compat-preload.mjs +89 -0
  96. package/host-service/lifecycle-compatibility.mjs +164 -0
  97. package/host-service/lifecycle-continuity-assets.mjs +222 -0
  98. package/host-service/lifecycle-continuity-probe.mjs +97 -0
  99. package/host-service/lifecycle-continuity-projection.mjs +29 -0
  100. package/host-service/lifecycle-continuity-runner.mjs +192 -0
  101. package/host-service/lifecycle-continuity.mjs +493 -0
  102. package/host-service/lifecycle-service-render-bundle.mjs +120 -0
  103. package/host-service/lifecycle-user-path.mjs +34 -0
  104. package/host-service/link-annotations.mjs +10 -0
  105. package/host-service/matrix-credential-issuer.mjs +32 -1
  106. package/host-service/matrix-event-correlator.mjs +166 -27
  107. package/host-service/matrix-facade.mjs +96 -19
  108. package/host-service/matrix-room-binding.mjs +24 -0
  109. package/host-service/matrix-transcript-read-adapter.mjs +17 -3
  110. package/host-service/model-discovery-guardian.mjs +40 -0
  111. package/host-service/model-discovery-process.mjs +141 -0
  112. package/host-service/onboard-lifecycle-status.mjs +28 -0
  113. package/host-service/onboard.mjs +479 -0
  114. package/host-service/package.json +2 -1
  115. package/host-service/portable-scheduler-definitions.mjs +28 -0
  116. package/host-service/product-runtime-host-origin.mjs +22 -0
  117. package/host-service/provider-model-params.mjs +14 -17
  118. package/host-service/provider-model-refresh-runner.mjs +124 -169
  119. package/host-service/provider-model-refresh-schedule.mjs +11 -0
  120. package/host-service/provider-resume-contract.mjs +9 -3
  121. package/host-service/restart-restore-state.mjs +25 -0
  122. package/host-service/retained-host-state-holder.mjs +57 -0
  123. package/host-service/routec-matrix-sqlite-store.mjs +155 -10
  124. package/host-service/routec-matrix-storage-adapter.mjs +208 -25
  125. package/host-service/routec-matrix-storage-paths.mjs +12 -0
  126. package/host-service/routec-request-delay-window.mjs +55 -0
  127. package/host-service/scheduler-acp-provider-runner.mjs +2 -1
  128. package/host-service/scheduler-direct-provider-runner.mjs +71 -9
  129. package/host-service/scheduler-run-timeout-policy.mjs +85 -0
  130. package/host-service/scheduler-service.mjs +96 -9
  131. package/host-service/scheduler-store.mjs +9 -2
  132. package/host-service/server.mjs +2202 -552
  133. package/host-service/service-definition-migration.mjs +10 -3
  134. package/host-service/session-collection-service.mjs +320 -0
  135. package/host-service/session-history.mjs +99 -15
  136. package/host-service/session-list-preview-service.mjs +87 -12
  137. package/host-service/session-list-projection-service.mjs +182 -0
  138. package/host-service/session-loop-definitions.mjs +702 -75
  139. package/host-service/session-manager.mjs +473 -38
  140. package/host-service/session-switcher-projection.mjs +11 -3
  141. package/host-service/session-tags.mjs +451 -0
  142. package/host-service/session-transcript-target.mjs +103 -0
  143. package/host-service/setup-input-file.mjs +91 -0
  144. package/host-service/setup-provider-model-defaults.mjs +53 -0
  145. package/host-service/setup.mjs +133 -101
  146. package/host-service/stopped-user-host-package.mjs +60 -0
  147. package/host-service/test-fixtures/p236-navigation-original.mjs +500 -0
  148. package/host-service/update-reminder-state.mjs +7 -0
  149. package/host-service/voice-input-receipt-store.mjs +60 -0
  150. package/package.json +1 -1
  151. package/static/oysterun-social-preview.png +0 -0
  152. package/static/site/assets/oysterun-social-preview.png +0 -0
  153. package/static/site/blog/en/index.html +17 -0
  154. package/static/site/blog/generated_manifest.json +9 -9
  155. package/static/site/blog/index.html +18 -0
  156. package/static/site/blog/voice-input-model-experiment/en/index.html +17 -0
  157. package/static/site/blog/voice-input-model-experiment/zh/index.html +17 -0
  158. package/static/site/blog/zh/index.html +17 -0
  159. package/static/site/docs/choose-and-recover-a-provider/en/index.html +17 -0
  160. package/static/site/docs/choose-and-recover-a-provider/zh/index.html +17 -0
  161. package/static/site/docs/configure-session-files-and-settings/en/index.html +17 -0
  162. package/static/site/docs/configure-session-files-and-settings/zh/index.html +17 -0
  163. package/static/site/docs/en/index.html +17 -0
  164. package/static/site/docs/generated_manifest.json +60 -60
  165. package/static/site/docs/host-authentication-and-connection/en/index.html +17 -0
  166. package/static/site/docs/host-authentication-and-connection/zh/index.html +17 -0
  167. package/static/site/docs/index.html +18 -0
  168. package/static/site/docs/install-and-use-a-skill/en/index.html +17 -0
  169. package/static/site/docs/install-and-use-a-skill/zh/index.html +17 -0
  170. package/static/site/docs/read-large-tool-results/en/index.html +17 -0
  171. package/static/site/docs/read-large-tool-results/zh/index.html +17 -0
  172. package/static/site/docs/recover-from-a-host-runtime-error/en/index.html +17 -0
  173. package/static/site/docs/recover-from-a-host-runtime-error/zh/index.html +17 -0
  174. package/static/site/docs/restore-work-after-a-host-restart/en/index.html +17 -0
  175. package/static/site/docs/restore-work-after-a-host-restart/zh/index.html +17 -0
  176. package/static/site/docs/send-images-and-media/en/index.html +17 -0
  177. package/static/site/docs/send-images-and-media/zh/index.html +17 -0
  178. package/static/site/docs/set-up-and-understand-a-host/en/index.html +17 -0
  179. package/static/site/docs/set-up-and-understand-a-host/zh/index.html +17 -0
  180. package/static/site/docs/understand-agent-activity/en/index.html +19 -2
  181. package/static/site/docs/understand-agent-activity/en/index.md +2 -2
  182. package/static/site/docs/understand-agent-activity/zh/index.html +19 -2
  183. package/static/site/docs/understand-agent-activity/zh/index.md +2 -2
  184. package/static/site/docs/use-the-oysterun-cli-safely/en/index.html +17 -0
  185. package/static/site/docs/use-the-oysterun-cli-safely/zh/index.html +17 -0
  186. package/static/site/docs/zh/index.html +17 -0
  187. package/static/site/en/index.html +18 -1
  188. package/static/site/en/index.md +1 -1
  189. package/static/site/faq/en/index.html +17 -0
  190. package/static/site/faq/index.html +18 -0
  191. package/static/site/faq/zh/index.html +17 -0
  192. package/static/site/getting-started/en/index.html +23 -1
  193. package/static/site/getting-started/en/index.md +7 -1
  194. package/static/site/getting-started/index.html +18 -0
  195. package/static/site/getting-started/zh/index.html +23 -1
  196. package/static/site/getting-started/zh/index.md +7 -1
  197. package/static/site/index.html +18 -0
  198. package/static/site/install.sh +676 -127
  199. package/static/site/overview/en/index.html +18 -1
  200. package/static/site/overview/en/index.md +2 -2
  201. package/static/site/overview/index.html +18 -0
  202. package/static/site/overview/zh/index.html +18 -1
  203. package/static/site/overview/zh/index.md +2 -2
  204. package/static/site/privacy/en/index.html +17 -0
  205. package/static/site/privacy/index.html +18 -0
  206. package/static/site/privacy/zh/index.html +17 -0
  207. package/static/site/zh/index.html +18 -1
  208. package/static/site/zh/index.md +1 -1
  209. package/static/site_source/content/tutorials/published/understand-agent-activity/en/article.html +2 -2
  210. package/static/site_source/content/tutorials/published/understand-agent-activity/zh/article.html +2 -2
  211. package/static/site_source/downloads/install.sh +676 -127
  212. package/static/site_source/manifests/host_install.json +2 -10
  213. package/static/site_source/pages/getting-started/en.html +6 -1
  214. package/static/site_source/pages/getting-started/zh.html +6 -1
  215. package/static/site_source/pages/home/en.html +1 -1
  216. package/static/site_source/pages/home/zh.html +1 -1
  217. package/static/site_source/pages/home_main.html +3 -2
  218. package/static/site_source/pages/overview/en.html +1 -1
  219. package/static/site_source/pages/overview/zh.html +1 -1
  220. package/tool_scripts/install_oysterun_update.mjs +78 -13
  221. package/tool_scripts/oysterun_continuity_transaction.mjs +438 -0
  222. package/tool_scripts/oysterun_npm_package_manifest.mjs +32 -0
  223. package/tool_scripts/oysterun_release_service.sh +5 -0
  224. package/tool_scripts/oysterun_service_common.sh +59 -24
  225. package/tool_scripts/oysterun_setup_input.sh +97 -0
  226. package/tool_scripts/oysterun_update_restart_runner.mjs +19 -3
  227. package/tool_scripts/p233_legacy_bootstrap.mjs +254 -0
  228. package/tool_scripts/resolve_user_host_port.mjs +15 -0
  229. package/tool_scripts/restart_oysterun.sh +8 -2
  230. package/tool_scripts/stack_dashboard_credentials.mjs +2 -1
  231. package/tool_scripts/status_oysterun_launch_agents.sh +13 -1
  232. package/tool_scripts/sync_host_stack_config.mjs +9 -6
  233. package/tool_scripts/test_environment_remote_cloud_deprovision.mjs +419 -0
  234. package/tool_scripts/uninstall_oysterun_launch_agents.sh +3 -5
  235. package/dev/client/web-chat/dist/assets/index-Ce9NZBZf.js +0 -129
  236. package/dev/client/web-chat/dist/assets/index-Ce9NZBZf.js.map +0 -1
  237. package/dev/client/web-chat/dist/assets/index-DzIClZ3T.js +0 -2
  238. package/dev/client/web-chat/dist/assets/index-DzIClZ3T.js.map +0 -1
  239. package/dev/client/web-chat/dist/assets/index-NYnaSGBb.css +0 -1
  240. package/dev/client/web-chat/dist/assets/pdf-ZjtalfgI.js.map +0 -1
  241. package/dev/client/web-chat/dist/assets/web-BzHcZK2M.js +0 -1
  242. package/dev/client/web-chat/dist/assets/web-c70YaskK.js +0 -1
@@ -6,53 +6,138 @@ platform=""
6
6
  architecture=""
7
7
  target_user=""
8
8
  target_home=""
9
+ target_uid=""
10
+ target_gid=""
11
+ canonical_home=""
12
+ config_dir=""
13
+ setup_input_file=""
14
+ setup_input_root_memory=false
9
15
  profile_file=""
10
- runtime_path_prefix=""
11
16
  temporary_root=""
17
+ install_lock=""
18
+ lock_owned=false
12
19
  dry_run=false
13
20
  install_only=false
14
- direct_root_account_created=false
21
+ reinstall=false
22
+ modify_path=true
23
+ package_install_spec=""
24
+ requested_channel_override=""
25
+ version_compare_script='const p=v=>{const m=String(v).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);return m?[+m[1],+m[2],+m[3],m[4]||""]:null},a=p(process.argv[1]),b=p(process.argv[2]);if(!a||!b)process.exit(2);let result=null;for(let i=0;i<3;i++){if(a[i]!==b[i]){result=a[i]>b[i]?1:-1;break}}if(result===null){if(a[3]===b[3])result=0;else if(!a[3])result=1;else if(!b[3])result=-1;else result=a[3].localeCompare(b[3],undefined,{numeric:true})>0?1:-1}console.log(result)'
15
26
 
16
27
  cleanup() {
17
28
  local exit_status=$?
29
+ unset setup_input_snapshot
18
30
  [[ -z "$temporary_root" ]] || rm -rf "$temporary_root"
19
- if [[ "$exit_status" -ne 0 && "$dry_run" != true && "$direct_root_account_created" == true ]]; then
20
- userdel -r "$target_user" >/dev/null 2>&1 || true
31
+ if [[ "$lock_owned" == true && -n "$install_lock" ]]; then
32
+ rmdir "$install_lock" >/dev/null 2>&1 || true
21
33
  fi
34
+ return "$exit_status"
35
+ }
36
+ print_recovery_inspection() {
37
+ # Signal handlers only print this command. They must not inspect or mutate
38
+ # a Host while an interrupted child may still own a lifecycle transaction.
39
+ if [[ -n "${node_bin:-}" && -n "${driver_prefix:-}" && -n "${config_dir:-}" && -n "${selected_channel:-}" && \
40
+ -f "$driver_prefix/lib/node_modules/oysterun/host-service/cli/installer-recovery-guidance.mjs" ]]; then
41
+ printf 'Next (read-only recovery inspection):' >&2
42
+ # Mirror capture_user's supported account routing without executing it.
43
+ if [[ "$EUID" -eq 0 ]]; then
44
+ if [[ "$platform" == "Darwin" ]]; then
45
+ printf ' %q' sudo -H -u "$target_user" env "HOME=$target_home" "USER=$target_user" "LOGNAME=$target_user" >&2
46
+ else
47
+ printf ' %q' runuser -u "$target_user" -- env "HOME=$target_home" "USER=$target_user" "LOGNAME=$target_user" >&2
48
+ fi
49
+ else
50
+ printf ' %q' env "HOME=$target_home" "USER=$target_user" "LOGNAME=$target_user" >&2
51
+ fi
52
+ printf ' %q' "$node_bin" "$driver_prefix/lib/node_modules/oysterun/host-service/cli/installer-recovery-guidance.mjs" \
53
+ "$config_dir" "${active_package_root:-}" "" "$selected_channel" \
54
+ "$canonical_home" "$target_uid" "$reinstall" >&2
55
+ printf '\n' >&2
56
+ elif [[ -n "${config_dir:-}" ]]; then
57
+ printf 'Next (inspect target ownership): ls -ld -- %q\n' "$config_dir" >&2
58
+ else
59
+ printf 'Next (inspect platform): uname -sm\n' >&2
60
+ fi
61
+ }
62
+ fail() {
63
+ trap - ERR
64
+ printf 'Oysterun installer failed during %s: %s\n' "$stage" "$1" >&2
65
+ if [[ -n "${node_bin:-}" && -n "${driver_prefix:-}" && -n "${active_package_root:-}" && \
66
+ -f "$driver_prefix/lib/node_modules/oysterun/host-service/cli/installer-recovery-guidance.mjs" ]]; then
67
+ capture_user "$node_bin" "$driver_prefix/lib/node_modules/oysterun/host-service/cli/installer-recovery-guidance.mjs" \
68
+ "$config_dir" "$active_package_root" "" "$selected_channel" \
69
+ "$canonical_home" "$target_uid" "$reinstall" >&2 || true
70
+ fi
71
+ print_recovery_inspection
72
+ exit 1
73
+ }
74
+ interrupted() {
75
+ trap - ERR INT TERM
76
+ printf 'Oysterun installer interrupted by %s during %s; completion and running Host readiness are unproved. Preserve the Host and lifecycle receipts.\n' "$1" "$stage" >&2
77
+ print_recovery_inspection
78
+ exit "$2"
22
79
  }
23
- fail() { trap - ERR; printf 'Oysterun installer failed during %s: %s\n' "$stage" "$1" >&2; exit 1; }
24
80
  trap 'fail "command exited with status $?"' ERR
25
81
  trap cleanup EXIT
82
+ trap 'interrupted INT 130' INT
83
+ trap 'interrupted TERM 143' TERM
26
84
 
27
85
  while [[ $# -gt 0 ]]; do
28
86
  case "$1" in
29
87
  --dry-run) dry_run=true ;;
30
88
  --install-only) install_only=true ;;
31
- *) fail "supported options: --dry-run, --install-only" ;;
89
+ --reinstall) reinstall=true ;;
90
+ --no-modify-path) modify_path=false ;;
91
+ --channel)
92
+ [[ $# -ge 2 ]] || fail "--channel requires latest or beta"
93
+ requested_channel_override="$2"
94
+ shift
95
+ ;;
96
+ --channel=*) requested_channel_override="${1#--channel=}" ;;
97
+ --setup-input-file)
98
+ [[ $# -ge 2 ]] || fail "--setup-input-file requires an absolute path"
99
+ setup_input_file="$2"; shift
100
+ ;;
101
+ --setup-input-file=*) setup_input_file="${1#--setup-input-file=}" ;;
102
+ --config-dir)
103
+ [[ $# -ge 2 ]] || fail "--config-dir requires an absolute path"
104
+ config_dir="$2"
105
+ shift
106
+ ;;
107
+ --config-dir=*) config_dir="${1#--config-dir=}" ;;
108
+ *) fail "supported options: --channel latest|beta, --reinstall (preserve config; stops sessions), --dry-run, --install-only, --no-modify-path, --config-dir PATH, --setup-input-file PATH" ;;
32
109
  esac
33
110
  shift
34
111
  done
35
112
 
113
+ if $reinstall && $install_only; then
114
+ fail "--reinstall includes service restoration and cannot be combined with --install-only"
115
+ fi
116
+
117
+ case "$requested_channel_override" in
118
+ ""|latest|beta) ;;
119
+ *) fail "--channel must be latest or beta" ;;
120
+ esac
121
+
36
122
  platform="$(uname -s)"
37
123
  architecture="$(uname -m)"
38
124
  case "$platform:$architecture" in
39
125
  Darwin:x86_64|Darwin:arm64|Linux:x86_64|Linux:aarch64|Linux:arm64) ;;
40
126
  Darwin:*|Linux:*) fail "unsupported CPU architecture: $architecture" ;;
41
- *) fail "supported operating systems: macOS and Debian/Ubuntu Linux" ;;
127
+ *) fail "supported operating systems: macOS and Ubuntu Linux" ;;
42
128
  esac
43
129
 
44
130
  if [[ "$platform" == "Linux" ]]; then
45
131
  os_release="/etc/os-release"
46
- if $dry_run && [[ -n "${P230_TEST_OS_RELEASE:-}" ]]; then os_release="$P230_TEST_OS_RELEASE"; fi
132
+ if $dry_run && [[ -n "${P230_TEST_OS_RELEASE:-}" ]]; then
133
+ os_release="$P230_TEST_OS_RELEASE"
134
+ fi
47
135
  [[ -f "$os_release" ]] || fail "Linux distribution metadata is missing"
48
136
  distro_id="$(. "$os_release"; printf '%s' "${ID:-}")"
49
137
  distro_version="$(. "$os_release"; printf '%s' "${VERSION_ID:-}")"
50
138
  distro_major="${distro_version%%.*}"
51
- case "$distro_id" in
52
- ubuntu) [[ "$distro_major" =~ ^[0-9]+$ && "$distro_major" -ge 22 ]] || fail "Ubuntu 22.04 or newer is required" ;;
53
- debian) [[ "$distro_major" =~ ^[0-9]+$ && "$distro_major" -ge 12 ]] || fail "Debian 12 or newer is required" ;;
54
- *) fail "supported Linux distributions: Ubuntu 22.04+ and Debian 12+" ;;
55
- esac
139
+ [[ "$distro_id" == "ubuntu" ]] || fail "supported Linux distribution: Ubuntu 22.04 or newer"
140
+ [[ "$distro_major" =~ ^[0-9]+$ && "$distro_major" -ge 22 ]] || fail "Ubuntu 22.04 or newer is required"
56
141
  fi
57
142
 
58
143
  lookup_home() {
@@ -75,182 +160,646 @@ else
75
160
  fi
76
161
 
77
162
  stage="target account"
163
+ # BEGIN generated setup input validation
164
+ # Canonical read-only setup-file/pre-input validation; also embedded in install.sh.
165
+ setup_input_error() { printf '%s\n' "$1" >&2; return 1; }
166
+ setup_config_has() {
167
+ local file="$1" key="$2" kind="$3" value
168
+ [[ -f "$file" ]] || return 1
169
+ if [[ "$(uname -s)" == Darwin ]]; then
170
+ local expected="$kind"; [[ "$kind" != direct ]] || expected=string
171
+ value=$(/usr/bin/plutil -extract "$key" raw -expect "$expected" -o - "$file" 2>/dev/null) || return 1
172
+ case "$kind" in bool) return 0;; direct) [[ "$value" == direct ]];; integer) [[ "$value" =~ ^[0-9]+$ ]] && (( value > 0 && value <= 65535 ));; *) [[ "$value" =~ [^[:space:]] ]];; esac
173
+ else
174
+ command -v python3 >/dev/null || { setup_input_error 'Read-only Ubuntu configuration preflight requires python3; no changes made'; return 2; }
175
+ python3 - "$file" "$key" "$kind" <<'PY'
176
+ import json,sys
177
+ try:
178
+ c=json.load(open(sys.argv[1]));v=c.get(sys.argv[2]);kind=sys.argv[3];ok=isinstance(v,bool) if kind=='bool' else (type(v)==int and 0<v<=65535) if kind=='integer' else v=='direct' if kind=='direct' else isinstance(v,str) and bool(v.strip())
179
+ except Exception: ok=False
180
+ sys.exit(0 if ok else 1)
181
+ PY
182
+ fi
183
+ }
184
+ setup_config_ready() {
185
+ local dir="$1" cfg="$1/config.json"
186
+ setup_config_has "$cfg" port integer && setup_config_has "$cfg" dashboard_user string && setup_config_has "$cfg" dashboard_password_hash string && setup_config_has "$cfg" default_browse_path string || return 1
187
+ if setup_config_has "$cfg" connection_mode direct; then
188
+ setup_config_has "$cfg" direct_host_url string || setup_config_has "$cfg" public_base_url string
189
+ else
190
+ setup_config_has "$cfg" managed_endpoint string &&
191
+ { setup_config_has "$dir/cloud_identity.json" device_id string || setup_config_has "$dir/cloud_identity.json" host_id string || setup_config_has "$cfg" device_id string || setup_config_has "$cfg" host_id string; } &&
192
+ { setup_config_has "$dir/cloud_identity.json" device_token string || setup_config_has "$dir/cloud_identity.json" host_credential string || setup_config_has "$cfg" device_token string || setup_config_has "$cfg" host_credential string; }
193
+ fi
194
+ }
195
+ setup_input_validate() {
196
+ set +x
197
+ set +a
198
+ unset setup_input_snapshot
199
+ setup_input_snapshot=''
200
+ local source="$1" uid="$2" config="$3" line key value mode owner missing='' seen='|' name='' root='' password='' consent=''
201
+ export -n password line value
202
+ if [[ -f "$config/config.json" && "$(uname -s)" == Linux ]] && ! command -v python3 >/dev/null; then setup_input_error "Read-only Ubuntu configuration preflight requires python3; no changes made"; return 1; fi
203
+ if [[ -f "$config/config.json" ]]; then
204
+ if [[ "$(uname -s)" == Darwin ]]; then
205
+ /usr/bin/osascript -l JavaScript -e 'function run(argv) { ObjC.import("Foundation"); const text = $.NSString.stringWithContentsOfFileEncodingError(argv[0], $.NSUTF8StringEncoding, null).js; const value = JSON.parse(text); if (!value || Array.isArray(value) || typeof value !== "object") throw Error("invalid configuration"); return "valid"; }' "$config/config.json" >/dev/null 2>&1 || { setup_input_error 'Existing configuration is invalid JSON; preserve it and restore the configuration before retrying'; return 1; }
206
+ else
207
+ python3 -c 'import json,sys; value=json.load(open(sys.argv[1])); assert isinstance(value,dict)' "$config/config.json" >/dev/null 2>&1 || { setup_input_error 'Existing configuration is invalid JSON; preserve it and restore the configuration before retrying'; return 1; }
208
+ fi
209
+ fi
210
+ if [[ -n "$source" ]]; then
211
+ [[ "$source" == /* && -f "$source" && ! -L "$source" ]] || { setup_input_error 'Setup input must be an absolute regular file, not a symlink'; return 1; }
212
+ local parent parent_path="${source%/*}"; [[ -n "$parent_path" ]] || parent_path=/
213
+ parent=$(cd -P "$parent_path" && pwd) || return 1
214
+ [[ "$parent" == "$parent_path" ]] || { setup_input_error "Setup input path must not traverse symlinks"; return 1; }
215
+ exec 9< "$source" || { setup_input_error 'Setup input is not readable'; return 1; }
216
+ local opened=/dev/fd/9; [[ "$(uname -s)" != Linux ]] || opened=/proc/self/fd/9
217
+ if [[ "$(uname -s)" == Darwin ]]; then
218
+ owner=$(/usr/bin/stat -Lf %u "$opened"); mode=$(/usr/bin/stat -Lf %Lp "$opened")
219
+ else
220
+ owner=$(/usr/bin/stat -Lc %u "$opened"); mode=$(/usr/bin/stat -Lc %a "$opened")
221
+ fi
222
+ if ! [[ -f "$opened" && -n "$uid" && "$owner" == "$uid" && ( "$mode" == 600 || "$mode" == 400 ) ]]; then
223
+ exec 9<&-; setup_input_error 'Setup input must belong to the selected source owner and have private permissions (0600 or 0400)'; return 1
224
+ fi
225
+ if IFS= read -r -d '' setup_input_snapshot <&9; then
226
+ exec 9<&-; unset setup_input_snapshot; setup_input_error 'Setup input must be UTF-8 text without NULs'; return 1
227
+ fi
228
+ exec 9<&-
229
+ builtin printf '%s' "$setup_input_snapshot" | /usr/bin/iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1 || { unset setup_input_snapshot; setup_input_error 'Setup input must be UTF-8 text without NULs'; return 1; }
230
+ while IFS= read -r line || [[ -n "$line" ]]; do
231
+ [[ -z "$line" || "$line" == \#* ]] && continue
232
+ [[ "$line" == *=* ]] || { setup_input_error 'Invalid setup input format'; return 1; }
233
+ key=${line%%=*}; value=${line#*=}
234
+ case "$key" in display_name|default_browse_root|dashboard_password|daily_telemetry_enabled) ;; *) setup_input_error 'Unknown setup input field'; return 1;; esac
235
+ [[ "$seen" != *"|$key|"* ]] || { setup_input_error 'Duplicate setup input field'; return 1; }; seen="$seen$key|"
236
+ case "$key" in
237
+ display_name) name="$value" ;;
238
+ default_browse_root) [[ -z "$value" || "$value" == /* ]] || { setup_input_error 'Setup Browse Root must be absolute'; return 1; }; root="$value" ;;
239
+ dashboard_password) password="$value" ;;
240
+ daily_telemetry_enabled) [[ "$value" == true || "$value" == false ]] || { setup_input_error 'Setup telemetry consent must be true or false'; return 1; }; consent="$value" ;;
241
+ esac
242
+ done < <(builtin printf '%s' "$setup_input_snapshot")
243
+ [[ -z "$root" || -d "$root" ]] || { setup_input_error "Setup Browse Root must be an existing directory"; return 1; }
244
+ fi
245
+ setup_config_ready "$config" && return 0
246
+ setup_config_has "$config/config.json" display_name string || [[ "$name" =~ [^[:space:]] ]] || missing="$missing display_name"
247
+ setup_config_has "$config/config.json" default_browse_path string || [[ -n "$root" ]] || missing="$missing default_browse_root"
248
+ setup_config_has "$config/config.json" dashboard_password_hash string || [[ "$password" =~ [^[:space:]] ]] || missing="$missing dashboard_password"
249
+ if ! { setup_config_has "$config/config.json" daily_telemetry_enabled bool && setup_config_has "$config/config.json" daily_telemetry_consent_recorded_at string; } && [[ -z "$consent" ]]; then missing="$missing daily_telemetry_enabled"; fi
250
+ [[ -z "$missing" ]] || { setup_input_error "Missing onboarding inputs:$missing. Resume the same command from a terminal or with --setup-input-file /absolute/private-file"; return 1; }
251
+ }
252
+
253
+ # Only the invoking-root account-creation branch may emit this memory snapshot.
254
+ # Anonymous pipes retain root ownership and read-only access in the target child.
255
+ setup_input_emit_for_uid() {
256
+ set +x
257
+ builtin printf 'oysterun-root-setup-v1:%s\n' "$1"
258
+ builtin printf '%s' "$setup_input_snapshot"
259
+ }
260
+ # END generated setup input validation
261
+ require_fresh_onboarding_terminal() {
262
+ local prospective_config="$1" prospective_uid="${2:-${target_uid:-}}"
263
+ if ! $dry_run && ! $install_only && { [[ -n "$setup_input_file" ]] || { [[ ! -t 0 ]] && ! ( : </dev/tty ) 2>/dev/null; }; }; then
264
+ setup_input_validate "$setup_input_file" "$prospective_uid" "$prospective_config" || fail "onboarding inputs are incomplete or unsafe; no changes made"
265
+ fi
266
+ }
78
267
  if [[ "$platform" == "Linux" && "$(id -u)" -eq 0 && "$target_user" == "oysterun" ]] && ! id "$target_user" >/dev/null 2>&1; then
268
+ if ! $dry_run && ! $install_only && [[ -n "$setup_input_file" ]]; then
269
+ setup_input_validate "$setup_input_file" 0 "${config_dir:-${OYSTERUN_CONFIG_DIR:-/home/oysterun/.oysterun}}" || fail "onboarding inputs are incomplete or unsafe; no changes made"
270
+ setup_input_root_memory=true
271
+ else
272
+ require_fresh_onboarding_terminal "${config_dir:-${OYSTERUN_CONFIG_DIR:-/home/oysterun/.oysterun}}"
273
+ fi
79
274
  if $dry_run; then
80
- printf '+ root: useradd --create-home --home-dir /home/oysterun --shell /bin/bash %q\n' "$target_user"
81
- direct_root_account_created=true
275
+ printf '+ root: useradd --create-home --home-dir /home/oysterun --shell /bin/bash oysterun\n'
82
276
  else
83
- useradd --create-home --home-dir /home/oysterun --shell /bin/bash "$target_user"
84
- direct_root_account_created=true
277
+ useradd --create-home --home-dir /home/oysterun --shell /bin/bash oysterun
85
278
  fi
86
279
  fi
87
280
 
88
281
  stage="target identity validation"
89
- if $dry_run && $direct_root_account_created; then
90
- target_uid="<created-user-uid>"
282
+ if $dry_run && [[ "$target_user" == "oysterun" ]] && ! id "$target_user" >/dev/null 2>&1; then
91
283
  target_home="/home/oysterun"
284
+ target_uid="1000"
285
+ target_gid="1000"
92
286
  canonical_home="$target_home"
93
287
  else
94
288
  target_home="$(lookup_home "$target_user")" || fail "target account has no home directory"
95
289
  [[ "$target_user" != "root" && "$target_home" == /* && "$target_home" != "/" && "$target_home" != "/root" ]] || fail "unsafe target user or home"
96
- if [[ "$(id -u)" -ne 0 && "${HOME:?HOME is required}" != "$target_home" ]]; then fail "HOME does not match the invoking account database home"; fi
290
+ if [[ "$(id -u)" -ne 0 && "${HOME:?HOME is required}" != "$target_home" ]]; then
291
+ fail "HOME does not match the invoking account database home"
292
+ fi
97
293
  target_uid="$(id -u "$target_user")" || fail "target user does not exist"
294
+ target_gid="$(id -g "$target_user")" || fail "target user group does not exist"
98
295
  [[ "$target_uid" =~ ^[0-9]+$ && "$target_uid" -ne 0 ]] || fail "target user must have a non-root numeric UID"
99
296
  [[ -d "$target_home" && ! -L "$target_home" ]] || fail "target home must be an existing non-symlink directory"
100
297
  canonical_home="$(cd -P "$target_home" && pwd -P)"
101
298
  [[ "$canonical_home" == "$target_home" ]] || fail "target home must already be canonical"
102
- if [[ "$platform" == "Darwin" ]]; then home_uid="$(stat -f %u "$target_home")"; else home_uid="$(stat -c %u "$target_home")"; fi
299
+ if [[ "$platform" == "Darwin" ]]; then
300
+ home_uid="$(stat -f %u "$target_home")"
301
+ else
302
+ home_uid="$(stat -c %u "$target_home")"
303
+ fi
103
304
  [[ "$home_uid" == "$target_uid" ]] || fail "target home must be owned by the target user"
104
305
  fi
105
306
 
106
- stage="existing installation check"
107
- default_config_dir="$canonical_home/.oysterun"
108
- if [[ -n "${OYSTERUN_CONFIG_DIR:-}" && "$OYSTERUN_CONFIG_DIR" != "$default_config_dir" ]]; then
109
- fail "custom OYSTERUN_CONFIG_DIR is ambiguous; use the existing installation's update command"
110
- fi
111
- existing_config_found=false
112
- [[ -f "$default_config_dir/config.json" ]] && existing_config_found=true
113
-
114
- if [[ "$platform" == "Linux" ]]; then
115
- command -v apt-get >/dev/null 2>&1 || fail "this release supports Debian and Ubuntu Linux (apt-get)"
116
- profile_file="$target_home/.profile"
117
- else
118
- profile_file="$target_home/.zprofile"
119
- fi
120
-
121
307
  run_root() {
122
- if $dry_run; then printf '+ root: '; printf '%q ' "$@"; printf '\n'
123
- elif [[ "$(id -u)" -eq 0 ]]; then "$@"
124
- else sudo "$@"
308
+ if $dry_run; then
309
+ printf '+ root:'
310
+ printf ' %q' "$@"
311
+ printf '\n'
312
+ elif [[ "$(id -u)" -eq 0 ]]; then
313
+ "$@"
314
+ else
315
+ sudo "$@"
125
316
  fi
126
317
  }
127
318
 
128
- run_user_shell() {
129
- local command="$1"
130
- command="set -Eeuo pipefail; unset NPM_CONFIG_PREFIX npm_config_prefix; $command"
131
- if [[ -n "$runtime_path_prefix" ]]; then command="export PATH='$runtime_path_prefix':\$PATH; $command"; fi
319
+ run_user() {
132
320
  if $dry_run; then
133
- printf '+ user %s (HOME=%s): %s\n' "$target_user" "$target_home" "$command"
321
+ printf '+ user %s (HOME=%s):' "$target_user" "$target_home"
322
+ printf ' %q' "$@"
323
+ printf '\n'
134
324
  elif [[ "$(id -u)" -eq 0 ]]; then
135
325
  if [[ "$platform" == "Darwin" ]]; then
136
- sudo -H -u "$target_user" env HOME="$target_home" USER="$target_user" LOGNAME="$target_user" /bin/bash -c "$command"
326
+ sudo -H -u "$target_user" env HOME="$target_home" USER="$target_user" LOGNAME="$target_user" "$@"
137
327
  else
138
- runuser -u "$target_user" -- env HOME="$target_home" USER="$target_user" LOGNAME="$target_user" /bin/bash -c "$command"
328
+ runuser -u "$target_user" -- env HOME="$target_home" USER="$target_user" LOGNAME="$target_user" "$@"
139
329
  fi
140
330
  else
141
- HOME="$target_home" USER="$target_user" LOGNAME="$target_user" /bin/bash -c "$command"
331
+ HOME="$target_home" USER="$target_user" LOGNAME="$target_user" "$@"
142
332
  fi
143
333
  }
144
334
 
145
- user_output() {
146
- local command="$1"
147
- command="set -Eeuo pipefail; unset NPM_CONFIG_PREFIX npm_config_prefix; $command"
148
- if [[ -n "$runtime_path_prefix" ]]; then command="export PATH='$runtime_path_prefix':\$PATH; $command"; fi
149
- if $dry_run; then
150
- HOME="$target_home" USER="$target_user" LOGNAME="$target_user" /bin/bash -c "$command"
151
- elif [[ "$(id -u)" -eq 0 ]]; then
335
+ capture_user() {
336
+ if [[ "$(id -u)" -eq 0 ]]; then
152
337
  if [[ "$platform" == "Darwin" ]]; then
153
- sudo -H -u "$target_user" env HOME="$target_home" USER="$target_user" LOGNAME="$target_user" /bin/bash -c "$command"
338
+ sudo -H -u "$target_user" env HOME="$target_home" USER="$target_user" LOGNAME="$target_user" "$@"
154
339
  else
155
- runuser -u "$target_user" -- env HOME="$target_home" USER="$target_user" LOGNAME="$target_user" /bin/bash -c "$command"
340
+ runuser -u "$target_user" -- env HOME="$target_home" USER="$target_user" LOGNAME="$target_user" "$@"
156
341
  fi
157
342
  else
158
- HOME="$target_home" USER="$target_user" LOGNAME="$target_user" /bin/bash -c "$command"
343
+ HOME="$target_home" USER="$target_user" LOGNAME="$target_user" "$@"
159
344
  fi
160
345
  }
161
346
 
162
- if $existing_config_found; then
163
- existing_cli="$(user_output 'command -v oysterun || true')"
164
- if [[ -n "$existing_cli" ]]; then
165
- fail "an existing configured Oysterun installation and CLI were found; use 'oysterun service:update --channel latest' from that user instead"
347
+ if [[ -z "$config_dir" ]]; then
348
+ config_dir="${OYSTERUN_CONFIG_DIR:-$canonical_home/.oysterun}"
349
+ fi
350
+ [[ "$config_dir" == /* ]] || fail "--config-dir must be absolute"
351
+ if [[ -e "$config_dir" ]]; then
352
+ [[ -d "$config_dir" && ! -L "$config_dir" ]] || fail "config path must be a non-symlink directory"
353
+ if [[ "$platform" == "Darwin" ]]; then
354
+ config_uid="$(stat -f %u "$config_dir")"
355
+ else
356
+ config_uid="$(stat -c %u "$config_dir")"
357
+ fi
358
+ [[ "$config_uid" == "$target_uid" ]] || fail "config directory is not owned by the target user"
359
+ fi
360
+ export OYSTERUN_CONFIG_DIR="$config_dir"
361
+ # Invoking-root FILE authority survives account creation, failures and repeats.
362
+ # Target-owned files retain their ordinary target-user path below.
363
+ if ! $dry_run && ! $install_only && [[ -n "$setup_input_file" && "$setup_input_root_memory" != true && "$platform" == Linux && "$(id -u)" -eq 0 ]] && [[ "$(stat -c %u "$setup_input_file" 2>/dev/null)" == 0 ]]; then
364
+ setup_input_validate "$setup_input_file" 0 "$config_dir" || fail "onboarding inputs are incomplete or unsafe; no changes made"
365
+ setup_input_root_memory=true
366
+ fi
367
+ if ! $dry_run && ! $install_only && [[ -n "$setup_input_file" && "$setup_input_root_memory" != true ]]; then
368
+ run_user test -r "$setup_input_file" || fail "setup input is not readable through the target user route; no changes made"
369
+ fi
370
+ if [[ "$setup_input_root_memory" != true ]]; then require_fresh_onboarding_terminal "$config_dir"; fi
371
+ if $reinstall && [[ "$config_dir" != "$canonical_home/.oysterun" ]]; then
372
+ fail "--reinstall targets the default user Host only; it cannot remove another stack's service"
373
+ fi
374
+
375
+ stage="installer lock"
376
+ state_root="$canonical_home/.local/state/oysterun"
377
+ install_lock="$state_root/install.lock"
378
+ run_user mkdir -p "$state_root"
379
+ if $dry_run; then
380
+ printf '+ acquire exclusive installer lock %q\n' "$install_lock"
381
+ else
382
+ if ! run_user mkdir "$install_lock" 2>/dev/null; then
383
+ fail "another Oysterun install, update, or repair is already running"
166
384
  fi
167
- printf 'Existing Oysterun configuration found; preserving it while reinstalling the missing CLI.\n'
385
+ lock_owned=true
168
386
  fi
169
387
 
170
- stage="Node.js runtime"
171
- node_major=0
172
- node_major="$(user_output "if command -v node >/dev/null 2>&1; then node -p 'Number(process.versions.node.split(\".\")[0])'; else printf 0; fi")"
173
- if [[ "$node_major" -ne 22 ]]; then
174
- if [[ "$platform" == "Darwin" ]]; then
175
- brew_command=""
176
- if [[ "${P230_ISOLATED_TEST_MODE:-}" == "true" ]]; then
177
- fixture_root="${P230_TEST_FIXTURE_ROOT:-}"
178
- test_brew="${P230_TEST_BREW_COMMAND:-}"
179
- [[ -n "$fixture_root" && -d "$fixture_root" && ! -L "$fixture_root" && -n "$test_brew" && -x "$test_brew" && ! -L "$test_brew" ]] || fail "isolated test mode requires an executable non-symlink Brew mock inside its fixture root"
180
- fixture_root="$(cd -P "$fixture_root" && pwd -P)"
181
- test_brew="$(cd -P "$(dirname "$test_brew")" && pwd -P)/$(basename "$test_brew")"
182
- [[ "$test_brew" == "$fixture_root"/* ]] || fail "isolated Brew mock must resolve inside its fixture root"
183
- brew_command="$test_brew"
184
- elif $dry_run && [[ "${P230_TEST_BREW_MODE:-}" == "missing" ]]; then
185
- brew_command=""
186
- elif $dry_run && [[ -n "${P230_TEST_BREW_COMMAND:-}" ]]; then
187
- brew_command="$P230_TEST_BREW_COMMAND"
388
+ if [[ "$platform" == "Darwin" ]]; then
389
+ profile_file="$canonical_home/.zprofile"
390
+ else
391
+ profile_file="$canonical_home/.profile"
392
+ fi
393
+
394
+ stage="Oysterun-managed Node.js 22"
395
+ runtime_root="$canonical_home/.local/share/oysterun/runtime"
396
+ case "$platform:$architecture" in
397
+ Darwin:arm64) node_suffix="darwin-arm64.tar.gz" ;;
398
+ Darwin:x86_64) node_suffix="darwin-x64.tar.gz" ;;
399
+ Linux:arm64|Linux:aarch64) node_suffix="linux-arm64.tar.gz" ;;
400
+ Linux:x86_64) node_suffix="linux-x64.tar.gz" ;;
401
+ esac
402
+
403
+ if $dry_run; then
404
+ node_version="v22.latest"
405
+ node_release_dir="$runtime_root/$node_version"
406
+ node_bin="$node_release_dir/bin/node"
407
+ npm_cli="$node_release_dir/lib/node_modules/npm/bin/npm-cli.js"
408
+ printf '+ download and verify official Node.js 22 %s artifact with SHASUMS256.txt\n' "$node_suffix"
409
+ printf '+ install versioned user-owned runtime at %q\n' "$node_release_dir"
410
+ else
411
+ temporary_root="$(mktemp -d)"
412
+ chmod 700 "$temporary_root"
413
+ curl --proto '=https' --tlsv1.2 -fsSLo "$temporary_root/SHASUMS256.txt" https://nodejs.org/dist/latest-v22.x/SHASUMS256.txt
414
+ node_archive="$(awk -v suffix="$node_suffix" 'index($2, suffix) == length($2) - length(suffix) + 1 { print $2; exit }' "$temporary_root/SHASUMS256.txt")"
415
+ [[ -n "$node_archive" ]] || fail "official Node.js 22 checksum manifest has no artifact for this platform"
416
+ node_version="${node_archive%%-$node_suffix}"
417
+ node_release_dir="$runtime_root/$node_version"
418
+ node_bin="$node_release_dir/bin/node"
419
+ npm_cli="$node_release_dir/lib/node_modules/npm/bin/npm-cli.js"
420
+ run_user mkdir -p "$runtime_root"
421
+ if [[ ! -x "$node_bin" ]] || ! "$node_bin" -e 'if (process.versions.node.split(".")[0] !== "22") process.exit(1)'; then
422
+ curl --proto '=https' --tlsv1.2 -fsSLo "$temporary_root/$node_archive" "https://nodejs.org/dist/latest-v22.x/$node_archive"
423
+ expected_sha="$(awk -v file="$node_archive" '$2 == file { print $1; exit }' "$temporary_root/SHASUMS256.txt")"
424
+ if command -v shasum >/dev/null 2>&1; then
425
+ actual_sha="$(shasum -a 256 "$temporary_root/$node_archive" | awk '{print $1}')"
426
+ elif command -v sha256sum >/dev/null 2>&1; then
427
+ actual_sha="$(sha256sum "$temporary_root/$node_archive" | awk '{print $1}')"
188
428
  else
189
- for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew; do [[ -x "$candidate" ]] && brew_command="$candidate" && break; done
190
- if [[ -z "$brew_command" ]]; then brew_command="$(user_output 'command -v brew || true')"; fi
429
+ fail "SHA-256 verification tool is required (shasum or sha256sum)"
191
430
  fi
192
- [[ -n "$brew_command" ]] || fail "Node.js 22 is required. Install it from https://nodejs.org/en/download, then run this installer again; no changes were made"
193
- if $dry_run; then
194
- printf '+ user %s: %s install node@22\n' "$target_user" "$brew_command"
195
- runtime_path_prefix="$target_home/.p230-dry-run-node22/bin"
431
+ [[ -n "$expected_sha" && "$actual_sha" == "$expected_sha" ]] || fail "official Node.js 22 artifact checksum mismatch"
432
+ tar -xzf "$temporary_root/$node_archive" -C "$temporary_root"
433
+ extracted="$temporary_root/${node_archive%.tar.gz}"
434
+ [[ -x "$extracted/bin/node" && -f "$extracted/lib/node_modules/npm/bin/npm-cli.js" ]] || fail "official Node.js archive is incomplete"
435
+ if [[ "$(id -u)" -eq 0 ]]; then
436
+ rm -rf "$node_release_dir.pending"
437
+ mv "$extracted" "$node_release_dir.pending"
438
+ chown -R "$target_uid:$target_gid" "$node_release_dir.pending"
439
+ mv "$node_release_dir.pending" "$node_release_dir"
196
440
  else
197
- run_user_shell "'$brew_command' install node@22"
198
- runtime_path_prefix="$(user_output "'$brew_command' --prefix node@22")/bin"
199
- export PATH="$runtime_path_prefix:$PATH"
441
+ mv "$extracted" "$node_release_dir"
200
442
  fi
201
- else
202
- run_root apt-get update
203
- run_root apt-get install -y ca-certificates curl gnupg
204
- if $dry_run; then
205
- printf '+ download https://deb.nodesource.com/setup_22.x to a protected unique temporary directory\n'
206
- printf '+ root: bash <protected-temporary-directory>/nodesource-setup.sh\n'
207
- else
208
- temporary_root="$(mktemp -d)"
209
- chmod 700 "$temporary_root"
210
- curl --proto '=https' --tlsv1.2 -fsSLo "$temporary_root/nodesource-setup.sh" https://deb.nodesource.com/setup_22.x
211
- run_root bash "$temporary_root/nodesource-setup.sh"
212
- rm -rf "$temporary_root"
213
- temporary_root=""
443
+ fi
444
+ "$node_bin" -e 'if (process.versions.node.split(".")[0] !== "22") process.exit(1)'
445
+ [[ -f "$npm_cli" ]] || fail "managed Node.js runtime does not include npm"
446
+ run_user ln -sfn "$node_release_dir" "$runtime_root/current"
447
+ fi
448
+ printf ' ✓ Oysterun-managed Node.js 22 verified: %s\n' "$node_version"
449
+ managed_runtime_path="$node_release_dir/bin:${PATH:-/usr/local/bin:/usr/bin:/bin}"
450
+
451
+ stage="release resolution"
452
+ npm_prefix="$canonical_home/.local/npm"
453
+ active_package_root="$npm_prefix/lib/node_modules/oysterun"
454
+ active_cli_module="$active_package_root/bin/oysterun.mjs"
455
+ installed_version=""
456
+ active_cli_usable=false
457
+ if ! $dry_run && [[ -f "$active_package_root/package.json" ]]; then
458
+ installed_version="$(capture_user "$node_bin" -p "require(process.argv[1]).version" "$active_package_root/package.json" 2>/dev/null || true)"
459
+ if [[ -f "$active_cli_module" ]] && capture_user "$node_bin" "$active_cli_module" --version >/dev/null 2>&1; then
460
+ active_cli_usable=true
461
+ fi
462
+ fi
463
+
464
+ recorded_channel=""
465
+ if ! $dry_run && [[ -f "$config_dir/config.json" ]]; then
466
+ recorded_channel="$(capture_user "$node_bin" -e 'const fs=require("fs");try{const c=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));process.stdout.write(typeof c.oysterun_update_channel==="string"?c.oysterun_update_channel:"")}catch{}' "$config_dir/config.json")"
467
+ fi
468
+ if [[ -n "$requested_channel_override" ]]; then
469
+ requested_spec="$requested_channel_override"
470
+ selected_channel="$requested_channel_override"
471
+ else
472
+ case "$recorded_channel" in
473
+ beta) requested_spec="beta"; selected_channel="beta" ;;
474
+ exact:*) requested_spec="${recorded_channel#exact:}"; selected_channel="$recorded_channel" ;;
475
+ latest|"") requested_spec="latest"; selected_channel="latest" ;;
476
+ *) fail "existing update channel metadata is invalid: $recorded_channel" ;;
477
+ esac
478
+ fi
479
+
480
+ if [[ -n "${OYSTERUN_INSTALL_TEST_PACKAGE:-}" ]]; then
481
+ [[ -n "${OYSTERUN_INSTALL_TEST_ROOT:-}" && -n "${OYSTERUN_INSTALL_TEST_VERSION:-}" ]] || fail "isolated test package requires TEST_ROOT and TEST_VERSION"
482
+ [[ "$OYSTERUN_INSTALL_TEST_ROOT" == /* && "$OYSTERUN_INSTALL_TEST_PACKAGE" == /* ]] || fail "isolated test paths must be absolute"
483
+ [[ -d "$OYSTERUN_INSTALL_TEST_ROOT" && ! -L "$OYSTERUN_INSTALL_TEST_ROOT" ]] || fail "isolated test root must be a non-symlink directory"
484
+ [[ -f "$OYSTERUN_INSTALL_TEST_PACKAGE" && ! -L "$OYSTERUN_INSTALL_TEST_PACKAGE" ]] || fail "isolated test package must be a regular non-symlink file"
485
+ test_root="$(cd -P "$OYSTERUN_INSTALL_TEST_ROOT" && pwd -P)"
486
+ test_package_dir="$(cd -P "$(dirname "$OYSTERUN_INSTALL_TEST_PACKAGE")" && pwd -P)"
487
+ test_package_path="$test_package_dir/$(basename "$OYSTERUN_INSTALL_TEST_PACKAGE")"
488
+ [[ "$test_package_path" == "$test_root/"* ]] || fail "isolated test package must be contained by TEST_ROOT"
489
+ [[ "$OYSTERUN_INSTALL_TEST_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]] || fail "isolated test version is invalid"
490
+ selected_version="$OYSTERUN_INSTALL_TEST_VERSION"
491
+ selected_channel="beta"
492
+ package_install_spec="$test_package_path"
493
+ printf '+ isolated verification package accepted inside %q\n' "$test_root"
494
+ elif $dry_run; then
495
+ selected_version="<resolved-$requested_spec-version>"
496
+ package_install_spec="oysterun@$selected_version"
497
+ printf '+ resolve one exact oysterun@%s package version\n' "$requested_spec"
498
+ else
499
+ selected_version="$(capture_user env PATH="$managed_runtime_path" NPM_CONFIG_PREFIX="$npm_prefix" "$node_bin" "$npm_cli" view "oysterun@$requested_spec" version --json | "$node_bin" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const v=JSON.parse(s);process.stdout.write(Array.isArray(v)?v[v.length-1]:v)})')"
500
+ [[ "$selected_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]] || fail "npm returned an invalid Oysterun version"
501
+ if [[ -n "$installed_version" && -z "$recorded_channel" ]]; then
502
+ comparison="$("$node_bin" -e "$version_compare_script" "$selected_version" "$installed_version")"
503
+ if [[ "$comparison" == "-1" ]]; then
504
+ selected_channel="exact:$installed_version"
505
+ selected_version="$installed_version"
506
+ if ( : </dev/tty ) 2>/dev/null; then
507
+ printf 'This Host runs Oysterun %s.\n' "$installed_version"
508
+ printf 'Public latest is older and would downgrade this persistent Host.\n\n'
509
+ printf ' 1) Keep %s and repair this exact version\n' "$installed_version"
510
+ printf ' 2) Continue on the Beta channel\n'
511
+ printf ' 3) Stop without changes\n\n'
512
+ printf 'Update choice [1]: '
513
+ IFS= read -r downgrade_choice </dev/tty || exit 130
514
+ case "${downgrade_choice:-1}" in
515
+ 1) ;;
516
+ 2)
517
+ selected_channel="beta"
518
+ selected_version="$(capture_user env PATH="$managed_runtime_path" NPM_CONFIG_PREFIX="$npm_prefix" "$node_bin" "$npm_cli" view oysterun@beta version --json | "$node_bin" -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s)))')"
519
+ ;;
520
+ 3) fail "stopped before version or service changes" ;;
521
+ *) fail "update choice must be 1, 2, or 3" ;;
522
+ esac
523
+ fi
214
524
  fi
215
- run_root apt-get install -y nodejs
216
525
  fi
526
+ package_install_spec="oysterun@$selected_version"
217
527
  fi
528
+ printf ' ✓ Oysterun release selected: %s (%s)\n' "$selected_version" "$selected_channel"
218
529
 
219
- stage="Node.js verification"
220
- if ! $dry_run; then
221
- user_output 'command -v node >/dev/null' || fail "Node.js installation completed without a node executable for the target user"
222
- node_major="$(user_output "node -p 'Number(process.versions.node.split(\".\")[0])'")"
223
- [[ "$node_major" -eq 22 ]] || fail "Node.js 22 is required"
224
- user_output 'command -v npm >/dev/null' || fail "npm is required for the target user"
530
+ stage="verified package staging"
531
+ driver_prefix="$canonical_home/.local/share/oysterun/bootstrap/$selected_version"
532
+ driver_cli_module="$driver_prefix/lib/node_modules/oysterun/bin/oysterun.mjs"
533
+ run_user mkdir -p "$npm_prefix" "$canonical_home/.local/bin" "$driver_prefix"
534
+ if $dry_run; then
535
+ printf '+ stage and verify oysterun@%s at %q\n' "$selected_version" "$driver_prefix"
536
+ else
537
+ capture_user env PATH="$managed_runtime_path" NPM_CONFIG_PREFIX="$driver_prefix" "$node_bin" "$npm_cli" install -g "$package_install_spec" --prefer-online --no-audit --no-fund
538
+ [[ -f "$driver_cli_module" ]] || fail "staged Oysterun CLI is missing"
539
+ staged_version="$(capture_user "$node_bin" -p "require(process.argv[1]).version" "$driver_prefix/lib/node_modules/oysterun/package.json")"
540
+ [[ "$staged_version" == "$selected_version" ]] || fail "staged Oysterun version does not match the selected release"
541
+ capture_user "$node_bin" --input-type=module - "$driver_prefix/lib/node_modules/oysterun" <<'PORT_CAPABILITY'
542
+ import { pathToFileURL } from 'node:url';
543
+ import path from 'node:path';
544
+ const root = process.argv[2];
545
+ let capability;
546
+ try { capability = await import(pathToFileURL(path.join(root, 'host-service/host-port-resolution.mjs')).href); }
547
+ catch { throw new Error('Selected package cannot preserve configured Host ports. Select a published release with this capability on your chosen channel; no service or active package was changed.'); }
548
+ if (capability.USER_HOST_PORT_CAPABILITY !== 1) throw new Error('Selected package lacks the required persisted-port capability.');
549
+ PORT_CAPABILITY
550
+ if ! $install_only && [[ -n "$setup_input_file" ]]; then
551
+ [[ -f "$driver_prefix/lib/node_modules/oysterun/host-service/setup-input-file.mjs" && -f "$driver_prefix/lib/node_modules/oysterun/tool_scripts/oysterun_setup_input.sh" ]] || fail "Selected package cannot accept --setup-input-file; select a capable release on your chosen channel. No service or active package was changed"
552
+ fi
553
+ capture_user "$node_bin" "$driver_cli_module" --version
554
+ capture_user "$node_bin" -e 'require(process.argv[1]);const Database=require(process.argv[2]);const db=new Database(":memory:");db.prepare("select 1").get();db.close()' "$driver_prefix/lib/node_modules/oysterun/node_modules/node-datachannel" "$driver_prefix/lib/node_modules/oysterun/node_modules/better-sqlite3"
225
555
  fi
556
+ printf ' ✓ Package prepared: %s; running Host qualification follows onboarding.\n' "$selected_version"
226
557
 
227
- stage="user-owned npm prefix"
228
- run_user_shell "mkdir -p \"\$HOME/.local/npm\"; npm config set prefix \"\$HOME/.local/npm\" --location=user; touch \"$profile_file\"; grep -qxF 'export PATH=\"\$HOME/.local/npm/bin:\$PATH\"' \"$profile_file\" || printf '\\nexport PATH=\"\$HOME/.local/npm/bin:\$PATH\"\\n' >> \"$profile_file\""
558
+ service_running=false
559
+ service_healthy=false
560
+ if ! $dry_run && [[ -f "$config_dir/config.json" ]]; then
561
+ stage="existing Host service binding"
562
+ # The parent supplies the contextual failure. Bash 3.2 otherwise inherits
563
+ # ERR inside this guarded command substitution and prints recovery twice.
564
+ staged_status="$(trap - ERR; capture_user env OYSTERUN_CONFIG_DIR="$config_dir" OYSTERUN_NODE_BIN="$node_bin" "$node_bin" "$driver_cli_module" service:status)" || fail "cannot bind the existing Host service; preserve its config/runtime and resolve the reported target mismatch before retrying"
565
+ printf '%s\n' "$staged_status" | grep -q '^health=yes$' && service_healthy=true
566
+ if printf '%s\n' "$staged_status" | grep -q '^loaded=yes$' && printf '%s\n' "$staged_status" | grep -Eq '^pid=[0-9]+$'; then
567
+ service_running=true
568
+ fi
569
+ fi
229
570
 
230
- if [[ -n "$runtime_path_prefix" ]]; then
231
- run_user_shell "grep -qxF 'export PATH=\"$runtime_path_prefix:\$PATH\"' \"$profile_file\" || printf '\\nexport PATH=\"$runtime_path_prefix:\$PATH\"\\n' >> \"$profile_file\""
571
+ # A stopped Host may still own an interrupted update. Preserve its active
572
+ # package and receipts, and use the existing exact staged recovery path before
573
+ # considering an ordinary package install. Explicit reinstall has its own
574
+ # narrower initial-preflight eligibility checks below.
575
+ if ! $dry_run && ! $reinstall && [[ "$service_running" != true && -f "$config_dir/config.json" ]]; then
576
+ retained_blocking="$(capture_user "$node_bin" --input-type=module - "$driver_prefix/lib/node_modules/oysterun" "$config_dir" <<'RETAINED_LIFECYCLE'
577
+ import path from 'node:path';
578
+ import { pathToFileURL } from 'node:url';
579
+ const [driver, configDir] = process.argv.slice(2);
580
+ const { readUpdateOperationState, isUpdateOperationBlocking } = await import(pathToFileURL(path.join(driver, 'host-service/update-reminder-state.mjs')).href);
581
+ process.stdout.write(isUpdateOperationBlocking(readUpdateOperationState({ configDir })) ? 'yes' : 'no');
582
+ RETAINED_LIFECYCLE
583
+ )"
584
+ if [[ "$retained_blocking" == yes ]]; then
585
+ stage="recover retained lifecycle before package replacement"
586
+ capture_user env OYSTERUN_CONFIG_DIR="$config_dir" OYSTERUN_RELEASE_STACK=production \
587
+ "$node_bin" "$driver_prefix/lib/node_modules/oysterun/host-service/cli/recover-installer-lifecycle.mjs" "$config_dir" "$active_package_root"
588
+ staged_status="$(capture_user env OYSTERUN_CONFIG_DIR="$config_dir" OYSTERUN_NODE_BIN="$node_bin" "$node_bin" "$driver_cli_module" service:status)"
589
+ printf '%s\n' "$staged_status" | grep -q '^health=yes$' && service_healthy=true
590
+ if printf '%s\n' "$staged_status" | grep -q '^loaded=yes$' && printf '%s\n' "$staged_status" | grep -Eq '^pid=[0-9]+$'; then
591
+ service_running=true
592
+ fi
593
+ [[ "$service_running" == true && "$service_healthy" == true ]] || fail "retained lifecycle recovery did not restore a healthy bound Host; its package and receipts were preserved"
594
+ installed_version="$(capture_user "$node_bin" -p "require(process.argv[1]).version" "$active_package_root/package.json")"
595
+ active_cli_usable=false
596
+ capture_user "$node_bin" "$active_cli_module" --version >/dev/null && active_cli_usable=true
597
+ fi
232
598
  fi
233
599
 
234
- stage="Oysterun package"
235
- run_user_shell 'export PATH="$HOME/.local/npm/bin:$PATH"; npm install -g oysterun@latest --prefer-online --no-audit --no-fund; command -v oysterun; oysterun --version; help_text="$(oysterun --help)"; grep -q "oysterun service:status" <<<"$help_text"; grep -q "oysterun show-qr" <<<"$help_text"'
600
+ if $reinstall; then
601
+ stage="preserve-config reinstall preflight"
602
+ printf 'Reinstall requested: running sessions will stop; saved configuration, Cloud identity, history and Agent folders are preserved.\n'
603
+ if $dry_run; then
604
+ printf '+ verify exact installed Host and reject in-progress or post-mutation recovery before service removal\n'
605
+ else
606
+ [[ "$active_cli_usable" == true && -f "$config_dir/config.json" ]] || fail "--reinstall requires the existing usable CLI and Host configuration"
607
+ reinstall_operation="$(capture_user "$node_bin" --input-type=module - "$driver_prefix/lib/node_modules/oysterun" "$active_package_root" "$config_dir" "$installed_version" "$selected_channel" "$canonical_home" "$target_uid" <<'REINSTALL_PREFLIGHT'
608
+ import path from 'node:path';
609
+ import { pathToFileURL } from 'node:url';
610
+ const [driver, active, configDir, version, channel, userHome, uid] = process.argv.slice(2);
611
+ const { inspectInstallerReinstall } = await import(pathToFileURL(path.join(driver, 'host-service/installer-recovery-guidance.mjs')).href);
612
+ const { operationId } = inspectInstallerReinstall({ configDir, activePackage: active, installedVersion: version, selectedChannel: channel, userHome, uid });
613
+ process.stdout.write(operationId);
614
+ REINSTALL_PREFLIGHT
615
+ )"
616
+ fi
617
+ stage="remove existing service while preserving configuration"
618
+ run_user env OYSTERUN_CONFIG_DIR="$config_dir" OYSTERUN_RELEASE_STACK=production OYSTERUN_NODE_BIN="$node_bin" \
619
+ "$node_bin" "$active_cli_module" uninstall --stack production --keep-config
620
+ if ! $dry_run && [[ -n "$reinstall_operation" ]]; then
621
+ stage="reconcile stopped initial-preflight failure"
622
+ run_user env OYSTERUN_CONFIG_DIR="$config_dir" "$node_bin" \
623
+ "$driver_prefix/lib/node_modules/oysterun/host-service/cli/reconcile-initial-preflight.mjs" \
624
+ --config-dir "$config_dir" --operation-id "$reinstall_operation"
625
+ fi
626
+ if ! $dry_run; then
627
+ stage="verify stopped Host before package replacement"
628
+ capture_user "$node_bin" --input-type=module - "$driver_prefix/lib/node_modules/oysterun" "$active_package_root" "$config_dir" <<'REINSTALL_STOPPED'
629
+ import fs from 'node:fs';
630
+ import path from 'node:path';
631
+ import { pathToFileURL } from 'node:url';
632
+ const [driver, active, configDir] = process.argv.slice(2);
633
+ const load = relative => import(pathToFileURL(path.join(driver, 'host-service', relative)).href);
634
+ const { readHostRuntimeMetadata } = await load('host-runtime-diagnostics.mjs');
635
+ const { processDefinitelyAbsent } = await load('initial-preflight-recovery.mjs');
636
+ const { readUpdateOperationState, isUpdateOperationBlocking } = await load('update-reminder-state.mjs');
637
+ const runtime = readHostRuntimeMetadata(configDir);
638
+ if (!runtime || runtime.config_dir !== configDir || runtime.stack_name !== 'production' ||
639
+ fs.realpathSync(runtime.repo_root) !== fs.realpathSync(active) || !processDefinitelyAbsent(runtime.pid)) {
640
+ throw new Error('The original Host has not been proven stopped; its active package was not replaced.');
641
+ }
642
+ if (isUpdateOperationBlocking(readUpdateOperationState({ configDir }))) {
643
+ throw new Error('A lifecycle operation changed during service removal; its evidence and package are preserved.');
644
+ }
645
+ REINSTALL_STOPPED
646
+ fi
647
+ service_running=false
648
+ service_healthy=false
649
+ fi
236
650
 
237
- stage="installation ownership verification"
651
+ package_changed=false
652
+ onboard_mode=()
653
+ [[ "$installed_version" != "$selected_version" ]] && package_changed=true
654
+ if [[ "$service_running" == true && "$service_healthy" != true ]]; then
655
+ fail "the existing Host is still running but unhealthy; its package was preserved. Inspect its service logs and use its supported lifecycle recovery before retrying"
656
+ fi
657
+ if [[ "$service_running" == true && "$package_changed" == true && "$selected_channel" == exact:* ]]; then
658
+ fail "the restored running version differs from the selected exact package; its working package was preserved. Choose a compatible update channel explicitly before retrying"
659
+ fi
660
+ if [[ "$service_running" == true && "$service_healthy" == true ]]; then
661
+ if [[ "$package_changed" == true && "$selected_channel" != exact:* ]]; then
662
+ onboard_mode+=(--update-required)
663
+ elif [[ "$active_cli_usable" != true ]]; then
664
+ onboard_mode+=(--repair-required)
665
+ fi
666
+ else
667
+ stage="active package installation"
668
+ if $dry_run; then
669
+ printf '+ install verified oysterun@%s into active user prefix %q\n' "$selected_version" "$npm_prefix"
670
+ else
671
+ capture_user "$node_bin" "$driver_prefix/lib/node_modules/oysterun/host-service/stopped-user-host-package.mjs" "$config_dir"
672
+ capture_user env PATH="$managed_runtime_path" NPM_CONFIG_PREFIX="$npm_prefix" "$node_bin" "$npm_cli" config set prefix "$npm_prefix" --location=user
673
+ capture_user env PATH="$managed_runtime_path" NPM_CONFIG_PREFIX="$npm_prefix" "$node_bin" "$npm_cli" install -g "$package_install_spec" --prefer-online --no-audit --no-fund
674
+ [[ -f "$active_cli_module" ]] || fail "active Oysterun CLI is missing"
675
+ active_version="$(capture_user "$node_bin" -p "require(process.argv[1]).version" "$active_package_root/package.json")"
676
+ [[ "$active_version" == "$selected_version" ]] || fail "active Oysterun version does not match the selected release"
677
+ capture_user "$node_bin" "$active_cli_module" --version
678
+ fi
679
+ if ! $reinstall && [[ "$package_changed" == true ]]; then
680
+ onboard_mode+=(--package-changed)
681
+ fi
682
+ fi
683
+
684
+ if $reinstall; then
685
+ stage="restore installed Host service"
686
+ # Install from the active package, never from the temporary bootstrap prefix.
687
+ # Explicit install recreates launchd and persisted Node/npm identity; start
688
+ # alone would leave macOS in pid-file mode after service uninstall.
689
+ run_user env OYSTERUN_CONFIG_DIR="$config_dir" OYSTERUN_RELEASE_STACK=production OYSTERUN_NODE_BIN="$node_bin" \
690
+ "$node_bin" "$active_cli_module" service:install --stack production
691
+ fi
692
+
693
+ stage="command launcher"
694
+ launcher_dir="$canonical_home/.local/bin"
695
+ launcher_path="$launcher_dir/oysterun"
696
+ launcher_text="#!/bin/sh
697
+ # Managed by Oysterun installer. Do not edit.
698
+ exec \"$node_bin\" \"$active_cli_module\" \"\$@\"
699
+ "
238
700
  if $dry_run; then
239
- printf '+ verify npm prefix=%q, executable=%q, and owner=%s\n' "$canonical_home/.local/npm" "$canonical_home/.local/npm/bin/oysterun" "$target_uid"
701
+ printf '+ write ownership-checked managed launcher %q\n' "$launcher_path"
702
+ else
703
+ if [[ -e "$launcher_path" || -L "$launcher_path" ]]; then
704
+ if [[ -f "$launcher_path" ]] && grep -q 'Managed by Oysterun installer' "$launcher_path"; then
705
+ :
706
+ elif [[ -L "$launcher_path" ]] && [[ "$(readlink "$launcher_path")" == "$npm_prefix/bin/oysterun" || "$(readlink "$launcher_path")" == "$active_cli_module" ]]; then
707
+ rm "$launcher_path"
708
+ else
709
+ fail "refused to replace a foreign command at $launcher_path"
710
+ fi
711
+ fi
712
+ if [[ "$(id -u)" -eq 0 ]]; then
713
+ temporary_launcher="$temporary_root/oysterun-user-launcher"
714
+ printf '%s' "$launcher_text" > "$temporary_launcher"
715
+ run_root install -o "$target_uid" -g "$target_gid" -m 755 "$temporary_launcher" "$launcher_path"
716
+ else
717
+ printf '%s' "$launcher_text" > "$launcher_path"
718
+ chmod 755 "$launcher_path"
719
+ fi
720
+ fi
721
+
722
+ path_has_launcher=false
723
+ case ":${PATH:-}:" in *":$launcher_dir:"*) path_has_launcher=true ;; esac
724
+ if [[ "$path_has_launcher" != true ]]; then
725
+ IFS=':' read -r -a inherited_path_parts <<< "${PATH:-}"
726
+ # Bash 3.2 treats an empty array as unset under nounset. Preserve zero
727
+ # arguments (not one empty argument), without disabling nounset globally.
728
+ for candidate_dir in ${inherited_path_parts[@]+"${inherited_path_parts[@]}"}; do
729
+ [[ "$candidate_dir" == "/usr/local/bin" || "$candidate_dir" == "/opt/homebrew/bin" ]] || continue
730
+ candidate="$candidate_dir/oysterun"
731
+ if [[ -e "$candidate" || -L "$candidate" ]]; then
732
+ if [[ -f "$candidate" ]] &&
733
+ grep -q 'Managed by Oysterun installer' "$candidate" &&
734
+ grep -Fq -- "$node_bin" "$candidate" &&
735
+ grep -Fq -- "$active_cli_module" "$candidate"; then
736
+ :
737
+ else
738
+ continue
739
+ fi
740
+ fi
741
+ if $dry_run; then
742
+ printf '+ install narrow system launcher %q using sudo only if required\n' "$candidate"
743
+ else
744
+ temporary_launcher="$temporary_root/oysterun-launcher"
745
+ printf '%s' "$launcher_text" > "$temporary_launcher"
746
+ chmod 755 "$temporary_launcher"
747
+ run_root install -m 755 "$temporary_launcher" "$candidate"
748
+ fi
749
+ launcher_path="$candidate"
750
+ path_has_launcher=true
751
+ break
752
+ done
753
+ fi
754
+
755
+ if [[ "$modify_path" == true ]]; then
756
+ run_user touch "$profile_file"
757
+ managed_begin="# >>> Oysterun managed PATH >>>"
758
+ managed_end="# <<< Oysterun managed PATH <<<"
759
+ if ! $dry_run; then
760
+ capture_user "$node_bin" -e 'const fs=require("fs"),p=process.argv[1],b=process.argv[2],e=process.argv[3],d=process.argv[4],esc=v=>v.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");let s=fs.readFileSync(p,"utf8");s=s.replace(new RegExp(`\\n?${esc(b)}[\\s\\S]*?${esc(e)}\\n?`,"g"),"\n").replace(/\\s*$/,"");fs.writeFileSync(p,`${s}\n\n${b}\nexport PATH="${d}:$PATH"\n${e}\n`)' "$profile_file" "$managed_begin" "$managed_end" "$launcher_dir"
761
+ fi
762
+ printf ' ✓ PATH configured for future terminals: %s\n' "$profile_file"
240
763
  else
241
- installed_prefix="$(user_output 'unset NPM_CONFIG_PREFIX npm_config_prefix; npm config get prefix')"
242
- installed_cli="$(user_output 'export PATH="$HOME/.local/npm/bin:$PATH"; command -v oysterun')"
243
- [[ "$installed_prefix" == "$canonical_home/.local/npm" && "$installed_cli" == "$canonical_home/.local/npm/bin/oysterun" ]] || fail "npm prefix or Oysterun executable is outside the target home"
244
- if [[ "$platform" == "Darwin" ]]; then cli_uid="$(stat -f %u "$installed_cli")"; else cli_uid="$(stat -c %u "$installed_cli")"; fi
245
- [[ "$cli_uid" == "$target_uid" ]] || fail "Oysterun executable is not owned by the target user"
764
+ printf ' ○ Shell profile unchanged (--no-modify-path)\n'
246
765
  fi
247
766
 
248
- stage="interactive setup"
767
+ stage="Oysterun onboarding"
768
+ onboard_cli_module="$driver_cli_module"
769
+ # A staged driver is needed to update an older live Host. Once the selected
770
+ # package is active, setup and service rendering must use that durable package.
771
+ if [[ "$service_running" != true || ( "$package_changed" != true && "$active_cli_usable" == true ) ]]; then
772
+ onboard_cli_module="$active_cli_module"
773
+ fi
249
774
  if $install_only; then
250
- printf 'Oysterun is installed for %s. Run oysterun setup when you are ready to configure and start this Host.\n' "$target_user"
775
+ printf 'Oysterun files are installed for %s; Host onboarding was intentionally not run (--install-only).\n' "$target_user"
251
776
  elif $dry_run; then
252
- printf '+ user %s (HOME=%s): oysterun\n' "$target_user" "$target_home"
777
+ printf '+ user %s: %q onboard --channel %q%s\n' "$target_user" "$onboard_cli_module" "$selected_channel" "${onboard_mode[*]:+ ${onboard_mode[*]}}"
778
+ else
779
+ # Reinstall and already-current Hosts intentionally have no mode flags.
780
+ # The guarded expansion also works with macOS system Bash 3.2 and set -u.
781
+ [[ -z "$setup_input_file" ]] || onboard_mode+=(--setup-input-file "$setup_input_file")
782
+ onboard_command=(env OYSTERUN_CONFIG_DIR="$config_dir" "$node_bin" "$onboard_cli_module" onboard --channel "$selected_channel" ${onboard_mode[@]+"${onboard_mode[@]}"})
783
+ if [[ "$setup_input_root_memory" == true ]]; then
784
+ # target_uid is the verified existing or canonically created account UID.
785
+ # Two root-owned anonymous read pipes let onboard and setup independently
786
+ # validate the same snapshot, with no disk copy or secret argv/environment.
787
+ run_user env OYSTERUN_SETUP_INPUT_FD=3 OYSTERUN_SETUP_INPUT_NEXT_FD=4 "${onboard_command[@]}" \
788
+ 3< <(setup_input_emit_for_uid "$target_uid") 4< <(setup_input_emit_for_uid "$target_uid")
789
+ elif ( : </dev/tty ) 2>/dev/null; then
790
+ run_user "${onboard_command[@]}" </dev/tty
791
+ else
792
+ run_user "${onboard_command[@]}"
793
+ fi
794
+ fi
795
+
796
+ stage="final command verification"
797
+ if ! $dry_run; then
798
+ capture_user "$launcher_path" --version
799
+ fi
800
+ printf ' ✓ Oysterun command verified: %s\n' "$launcher_path"
801
+ if [[ "$path_has_launcher" == true ]]; then
802
+ printf 'Current terminal command: oysterun\n'
253
803
  else
254
- printf 'Oysterun is installed for %s. Starting guided setup now.\n' "$target_user"
255
- run_user_shell 'export PATH="$HOME/.local/npm/bin:$PATH"; exec oysterun'
804
+ printf 'Current terminal: export PATH="%s:$PATH"\n' "$launcher_dir"
256
805
  fi